diff --git a/SVSim.BattleNode/Protocol/BattleResult.cs b/SVSim.BattleNode/Protocol/BattleResult.cs
index 01289fc..0c2d4b7 100644
--- a/SVSim.BattleNode/Protocol/BattleResult.cs
+++ b/SVSim.BattleNode/Protocol/BattleResult.cs
@@ -54,15 +54,21 @@ public enum BattleResult
/// Wire-equivalent of RESULT_CODE.Invalid = 2. Don't use for new code.
Consistency = 2,
- /// 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
+ /// Player won by reducing opponent's life to 0. Pushed to the winner
+ /// on TurnEndFinal. 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).
+ /// Player lost by their own life dropping to 0. Pushed to the loser on
+ /// the opponent's TurnEndFinal. Prod TK2 capture at
+ /// data_dumps/captures/battle-traffic_tk2_regular.ndjson:274 is a loss
+ /// shown to the player from a real opponent's lethal.
LifeLose = 102,
+
+ /// Player won because opponent retired. Pushed to the survivor on
+ /// Retire/Kill. Same player-perspective convention as the Life codes.
+ RetireWin = 105,
+
+ /// Player lost by retiring. Pushed to the retirer on Retire/Kill.
+ RetireLose = 106,
}
diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs
index 7d08ae4..38dfb08 100644
--- a/SVSim.BattleNode/Sessions/BattleSession.cs
+++ b/SVSim.BattleNode/Sessions/BattleSession.cs
@@ -239,50 +239,29 @@ public sealed class BattleSession
// 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.
+ // TurnEndFinal: client signals the player's FINAL turn is over (game-end
+ // condition met, usually killed opponent's leader). Unified across types:
+ // forward the envelope to other (matches prod TK2 capture
+ // battle-traffic_tk2_regular.ndjson:273 — loser-side receives TurnEndFinal
+ // from server before BattleFinish), then push BattleFinish per-side with
+ // player-perspective codes (LifeWin to winner, LifeLose to loser).
+ // ScriptedBotParticipant no longer reacts to TurnEndFinal (only TurnEnd) —
+ // this dispatch arm owns it. NoOpBotParticipant swallows. Phase → Terminal
+ // so the RunAsync cascade doesn't synthesize a follow-up 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 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.LifeWin), true));
- Phase = BattleSessionPhase.Terminal;
- }
- // Bot type: no-op (rank-battle finish is HTTP-driven via /ai_*/finish).
+ result.Add((other, env, false));
+ result.Add((from, BuildBattleFinish(BattleResult.LifeWin), true));
+ result.Add((other, BuildBattleFinish(BattleResult.LifeLose), true));
+ Phase = BattleSessionPhase.Terminal;
break;
+ // Retire / Kill: sender concedes (Retire) or the client requested an immediate
+ // terminate (Kill). Unified across types: push BattleFinish per-side with the
+ // proper retire codes. Bots swallow their push (no real-opponent state).
case NetworkBattleUri.Retire:
case NetworkBattleUri.Kill:
- if (Type == BattleType.Pvp)
- {
- result.Add((from, BuildBattleFinish(BattleResult.Lose), true));
- result.Add((other, BuildBattleFinish(BattleResult.Win), true));
- }
- else
- {
- // Scripted (and future Bot) — sender wins by default (no real opponent).
- result.Add((from, BuildBattleFinishNoContest(), true));
- }
+ result.Add((from, BuildBattleFinish(BattleResult.RetireLose), true));
+ result.Add((other, BuildBattleFinish(BattleResult.RetireWin), true));
Phase = BattleSessionPhase.Terminal;
break;
@@ -304,14 +283,17 @@ public sealed class BattleSession
result.Add((other, env, false));
break;
- // --- PvP gameplay forwarding (post-AfterReady).
- // Order matters: this MUST come after the FakeOpponentViewerId arms so
- // Scripted bot emissions don't fall into the PvP forwarder.
- case NetworkBattleUri.TurnStart when Type == BattleType.Pvp && BothAfterReady():
- case NetworkBattleUri.PlayActions when Type == BattleType.Pvp && BothAfterReady():
- case NetworkBattleUri.Echo when Type == BattleType.Pvp && BothAfterReady():
- case NetworkBattleUri.TurnEndActions when Type == BattleType.Pvp && BothAfterReady():
- case NetworkBattleUri.JudgeResult when Type == BattleType.Pvp && BothAfterReady():
+ // Gameplay-frame forwarding (post-AfterReady). Unified across types:
+ // BothAfterReady() is only true when both participants are RealParticipants
+ // (ScriptedBot/NoOpBot don't implement IHasHandshakePhase so their Phase is
+ // always null), so this arm naturally fires for PvP only. Order matters:
+ // this MUST come after the FakeOpponentViewerId arms so Scripted bot
+ // emissions don't fall into this forwarder.
+ case NetworkBattleUri.TurnStart when BothAfterReady():
+ case NetworkBattleUri.PlayActions when BothAfterReady():
+ case NetworkBattleUri.Echo when BothAfterReady():
+ case NetworkBattleUri.TurnEndActions when BothAfterReady():
+ case NetworkBattleUri.JudgeResult when BothAfterReady():
result.Add((other, env, false));
break;
diff --git a/SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs b/SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs
index 9d3176d..3f029d0 100644
--- a/SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs
+++ b/SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs
@@ -37,9 +37,12 @@ public sealed class ScriptedBotParticipant : IBattleParticipant
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
{
- // v1.2 behavior: react to the player's TurnEnd / TurnEndFinal with the
- // three-frame burst. Everything else is silently swallowed.
- if (envelope.Uri is NetworkBattleUri.TurnEnd or NetworkBattleUri.TurnEndFinal)
+ // React to the player's TurnEnd with the three-frame burst (TurnStart / TurnEnd /
+ // Judge) — that's the v1.2 "scripted bot takes its turn" behavior. Everything else
+ // (including TurnEndFinal) is silently swallowed: TurnEndFinal is the player's
+ // game-end signal and is handled directly by the BattleSession dispatch arm, which
+ // pushes BattleFinish per-side; the bot doesn't need to react.
+ if (envelope.Uri is NetworkBattleUri.TurnEnd)
{
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false);
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);
diff --git a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs
index 279700f..d37c421 100644
--- a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs
+++ b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs
@@ -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]
diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
index 8de414f..598ff7a 100644
--- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
+++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
@@ -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());
- 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()
diff --git a/SVSim.UnitTests/BattleNode/Sessions/Participants/ScriptedBotParticipantTests.cs b/SVSim.UnitTests/BattleNode/Sessions/Participants/ScriptedBotParticipantTests.cs
index 27c1573..2c29156 100644
--- a/SVSim.UnitTests/BattleNode/Sessions/Participants/ScriptedBotParticipantTests.cs
+++ b/SVSim.UnitTests/BattleNode/Sessions/Participants/ScriptedBotParticipantTests.cs
@@ -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();
- 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]