refactor(battle-node): unify TurnEndFinal / Retire-Kill / gameplay-forwarder dispatch across types

Three dispatch arms had Type-based branching that was either wrong or
unnecessary. Unified per the audit doc's recommended order, grounded in
verified facts about each participant's PushAsync.

(1) TurnEndFinal — was branched: PvP broadcast TurnEnd+Judge (wrong on a
game-end signal); Scripted pushed BattleFinish(LifeWin). Unified:
  - forward the envelope to other (matches prod TK2 capture
    battle-traffic_tk2_regular.ndjson:273 — loser receives TurnEndFinal
    from server before BattleFinish)
  - push BattleFinish(LifeWin) to from (winner)
  - push BattleFinish(LifeLose) to other (loser)
  - Phase → Terminal

  Requires ScriptedBotParticipant.PushAsync to no longer fire its 3-frame
  burst on TurnEndFinal (previously it reacted to both TurnEnd and
  TurnEndFinal). The dispatch arm now owns TurnEndFinal's response; the
  bot reacting too would race with the BattleFinish push. Bot still
  fires on regular TurnEnd as before.

(2) Retire / Kill — was branched: PvP pushed Lose=0 (NotFinish) /
Win=1 (NoContest); Scripted pushed BuildBattleFinishNoContest() (Win=1).
Both shipped wrong RESULT_CODE values; the audit doc's outstanding item
documented this. Unified:
  - push BattleFinish(RetireLose=106) to from (the retirer)
  - push BattleFinish(RetireWin=105) to other (the survivor)
  - Phase → Terminal

  Added RetireWin=105 / RetireLose=106 to BattleResult enum with the
  same player-perspective convention.

(3) PvP gameplay forwarder (TurnStart / PlayActions / Echo /
TurnEndActions / JudgeResult) — had a redundant `Type == BattleType.Pvp`
guard. Verified that BothAfterReady() is naturally only true when both
participants are RealParticipant (ScriptedBot / NoOpBot don't implement
IHasHandshakePhase per RealParticipant.cs:20-23 / Participants/*.cs grep).
Dropped the redundant guard.

Bot type still has its dedicated InitBattle/Loaded/TurnEnd arms above
the unified ones, so Bot-specific behavior is unchanged.

Tests: 177 battle-node tests passing.
- Updated 9 tests to match the unified dispatch (paired BattleFinish
  pushes, correct RESULT_CODE values, forwarded TurnEndFinal envelope).
- ScriptedBotParticipantTests.PushAsync_TurnEndFinal_* rewritten to
  assert the bot does NOT fire on TurnEndFinal (was asserting it did).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-02 18:37:24 -04:00
parent 1685b509c3
commit 0a8a84b2cc
6 changed files with 152 additions and 116 deletions

View File

@@ -281,9 +281,10 @@ public class BattleNodeFlowTests
Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
var aBody = (RawBody)aFinish.Body;
var bBody = (RawBody)bFinish.Body;
// BattleResult.Lose = 0, Win = 1.
Assert.That((long)aBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Lose));
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Win));
// BattleResult.RetireLose = 106 (retirer), RetireWin = 105 (survivor). Player-
// perspective codes per the FinishBattleEffect trace.
Assert.That((long)aBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.RetireLose));
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.RetireWin));
}
[Test]

View File

@@ -83,18 +83,13 @@ public class BattleSessionDispatchTests
}
[Test]
public void Scripted_TurnEndFinal_pushes_BattleFinish_LifeWin_to_player_and_terminates()
public void Scripted_TurnEndFinal_forwards_envelope_and_pushes_paired_BattleFinish()
{
// 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.
//
// 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.)
// Unified TurnEndFinal handling: forward the envelope to other (matches prod
// capture battle-traffic_tk2_regular.ndjson:273) + push BattleFinish per-side
// with player-perspective codes (LifeWin to winner, LifeLose to loser).
// In Scripted mode the "loser" is a ScriptedBotParticipant; the loser-side
// BattleFinish push is harmless (bot swallows non-TurnEnd URIs).
var (s, a, b) = NewSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
@@ -102,17 +97,29 @@ public class BattleSessionDispatchTests
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.LifeWin),
"Wire result must be RESULT_CODE.LifeWin (101) — names are player-perspective; client routes this to WIN UI.");
Assert.That(routes.Count, Is.EqualTo(3),
"TurnEndFinal must produce: forwarded envelope + BattleFinish(LifeWin) to from + BattleFinish(LifeLose) to other.");
// Route 0: forwarded TurnEndFinal envelope to other.
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndFinal));
// Route 1: BattleFinish(LifeWin) to from (the winner who declared the final turn).
Assert.That(routes[1].Target, Is.SameAs(a));
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(routes[1].NoStock, Is.True);
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
Assert.That(winBody.Result, Is.EqualTo(BattleResult.LifeWin),
"Winner gets LifeWin (101) — player-perspective: 'I won by life' → WIN UI.");
// Route 2: BattleFinish(LifeLose) to other (the loser).
Assert.That(routes[2].Target, Is.SameAs(b));
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(routes[2].NoStock, Is.True);
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[2].Frame.Body;
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.LifeLose),
"Loser gets LifeLose (102) — player-perspective: 'I lost by life' → LOSE UI.");
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal),
"Session must transition to Terminal so the RunAsync cascade doesn't synthesize a second BattleFinish.");
}
@@ -189,28 +196,46 @@ public class BattleSessionDispatchTests
}
[Test]
public void Retire_pushes_BattleFinish_no_contest_terminates()
public void Retire_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
{
var (s, a, _) = NewSession();
var (s, a, b) = NewSession();
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(routes[0].NoStock, Is.True);
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose),
"Retirer gets RetireLose=106 — player-perspective: 'I lost by retire'.");
Assert.That(routes[1].Target, Is.SameAs(b));
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(routes[1].NoStock, Is.True);
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin),
"Survivor gets RetireWin=105. In Scripted mode the bot swallows it; in PvP the opponent renders 'opponent retired'.");
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
[Test]
public void Kill_pushes_BattleFinish_no_contest_terminates()
public void Kill_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
{
var (s, a, _) = NewSession();
var (s, a, b) = NewSession();
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(routes[0].NoStock, Is.True);
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(routes[1].Target, Is.SameAs(b));
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
@@ -395,17 +420,29 @@ public class BattleSessionDispatchTests
}
[Test]
public void Pvp_TurnEndFinal_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both()
public void Pvp_TurnEndFinal_from_A_forwards_envelope_to_B_and_pushes_paired_BattleFinish()
{
// Same unified handling as Scripted — A is the winner, B is the loser.
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
Assert.That(routes.Count, Is.EqualTo(4));
Assert.That(routes.Select(r => r.Frame.Uri).Distinct(),
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
Assert.That(routes.Count, Is.EqualTo(3));
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndFinal));
Assert.That(routes[1].Target, Is.SameAs(a));
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.LifeWin));
Assert.That(routes[2].Target, Is.SameAs(b));
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)routes[2].Frame.Body).Result, Is.EqualTo(BattleResult.LifeLose));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
[Test]
@@ -421,7 +458,7 @@ public class BattleSessionDispatchTests
}
[Test]
public void Pvp_Retire_from_A_pushes_BattleFinish_Lose_to_A_and_Win_to_B()
public void Pvp_Retire_from_A_pushes_RetireLose_to_A_and_RetireWin_to_B()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
@@ -433,9 +470,9 @@ public class BattleSessionDispatchTests
var aRoute = routes.Single(r => ReferenceEquals(r.Target, a));
var bRoute = routes.Single(r => ReferenceEquals(r.Target, b));
Assert.That(aRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Lose));
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Win));
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
Assert.That(aRoute.NoStock, Is.True);
Assert.That(bRoute.NoStock, Is.True);
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
@@ -455,15 +492,20 @@ public class BattleSessionDispatchTests
}
[Test]
public void Scripted_Retire_still_pushes_BattleFinish_Win_to_sender_only()
public void Scripted_Retire_pushes_RetireLose_to_player_and_RetireWin_to_bot()
{
// Regression guard — Phase 1 behavior preserved for Scripted.
var (s, a, _) = NewSession();
// Unified with PvP — paired BattleFinish per-side. In Scripted mode the "loser"
// is a ScriptedBotParticipant; its loser-side push is swallowed (it only reacts
// to TurnEnd). The wire-correct codes are still emitted in case future work
// wants to inspect them or run a real two-real-participant session.
var (s, a, b) = NewSession();
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.Win));
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(routes[1].Target, Is.SameAs(b));
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
}
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
@@ -593,9 +635,11 @@ public class BattleSessionDispatchTests
}
[Test]
public void Bot_Retire_pushes_BattleFinish_Win_to_sender()
public void Bot_Retire_pushes_paired_BattleFinish_RetireLose_to_player_RetireWin_to_bot()
{
var (s, a, _) = NewBotSession();
// Unified Retire/Kill dispatch — same paired push as Scripted and PvP.
// NoOpBotParticipant swallows its push.
var (s, a, b) = NewBotSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
@@ -603,9 +647,11 @@ public class BattleSessionDispatchTests
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(routes[1].Target, Is.SameAs(b));
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
}
private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession()

View File

@@ -28,22 +28,20 @@ public class ScriptedBotParticipantTests
}
[Test]
public async Task PushAsync_TurnEndFinal_fires_three_FrameEmitted_in_order()
public async Task PushAsync_TurnEndFinal_does_NOT_fire_burst()
{
// Same burst as TurnEnd — TurnEndFinal is the game-ending variant but the
// bot's response shape is unchanged for v1.2 behaviour preservation.
// TurnEndFinal is the game-end signal — owned by BattleSession's TurnEndFinal
// dispatch arm, which pushes BattleFinish per-side. The bot no longer reacts to
// it; reacting would race the BattleFinish with the no-longer-needed 3-frame
// burst. Only regular TurnEnd triggers the burst.
var p = new ScriptedBotParticipant();
var emitted = new List<NetworkBattleUri>();
p.FrameEmitted += (env, _) => { emitted.Add(env.Uri); return Task.CompletedTask; };
var fired = 0;
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEndFinal), noStock: false, CancellationToken.None);
Assert.That(emitted, Is.EqualTo(new[]
{
NetworkBattleUri.TurnStart,
NetworkBattleUri.TurnEnd,
NetworkBattleUri.Judge,
}));
Assert.That(fired, Is.EqualTo(0),
"TurnEndFinal must not trigger the bot's burst — the dispatch arm pushes BattleFinish directly.");
}
[Test]