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]