From 8a5b8b747d015db49f43361e877d29390ea8044f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 1 Jun 2026 17:32:22 -0400 Subject: [PATCH] feat(battle-node): BuildOpponentJudge builder for v1.2 turn-end Judge Adds the third frame of the burst. Wire shape from prod (spin + resultCode). OpponentJudgeSpin const next to OpponentTurnStartSpin for consistency. Single test locks uri, ViewerId, Cat, and body shape. --- SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs | 10 ++++++++++ SVSim.BattleNode/Lifecycle/ScriptedProfiles.cs | 7 +++++++ .../BattleNode/Lifecycle/ScriptedLifecycleTests.cs | 13 +++++++++++++ 3 files changed, 30 insertions(+) diff --git a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs index 0b9e6d6..478d6fc 100644 --- a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs +++ b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs @@ -117,6 +117,16 @@ public static class ScriptedLifecycle public static MsgEnvelope BuildOpponentTurnEnd() => EnvelopeForPush(NetworkBattleUri.TurnEnd, new TurnEndBody(TurnState: 0)); + /// + /// Server-pushed Judge frame that follows the opponent's TurnEnd and unblocks the + /// client's JudgeOperationControlTurnStartPlayer, transitioning to the + /// player's next turn. Without this frame the client hangs on "Opponent's turn…" — + /// see data_dumps/captures/battle-traffic.ndjson line 14 (client emits its own + /// Judge then waits forever). + /// + public static MsgEnvelope BuildOpponentJudge() => + EnvelopeForPush(NetworkBattleUri.Judge, new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin)); + private static IReadOnlyList BuildPosIdxList(IReadOnlyList hand) { var list = new List(hand.Count); diff --git a/SVSim.BattleNode/Lifecycle/ScriptedProfiles.cs b/SVSim.BattleNode/Lifecycle/ScriptedProfiles.cs index 0958b85..386a474 100644 --- a/SVSim.BattleNode/Lifecycle/ScriptedProfiles.cs +++ b/SVSim.BattleNode/Lifecycle/ScriptedProfiles.cs @@ -51,4 +51,11 @@ internal static class ScriptedProfiles // display state. v1 doesn't simulate the opponent — once this lands, // the client sits there indefinitely. public const int OpponentTurnStartSpin = 100; + + /// + /// Server-pushed Judge frame spin value. Prod varies per push (55, 175, 73, ...) — it's + /// an animation seed, not a stateful value. Fixed at 100 here for test stability; + /// the client's JudgeOperation doesn't read it. + /// + public const int OpponentJudgeSpin = 100; } diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs index c4c3881..1cd5ed0 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs @@ -166,6 +166,19 @@ public class ScriptedLifecycleTests Assert.That(body.ResultCode, Is.EqualTo(1)); } + [Test] + public void BuildOpponentJudge_emits_Judge_uri_with_spin_and_default_result_code() + { + var env = ScriptedLifecycle.BuildOpponentJudge(); + + Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Judge)); + Assert.That(env.ViewerId, Is.EqualTo(ScriptedLifecycle.FakeOpponentViewerId)); + Assert.That(env.Cat, Is.EqualTo(EmitCategory.Battle)); + var body = (JudgeBody)env.Body; + Assert.That(body.Spin, Is.EqualTo(ScriptedProfiles.OpponentJudgeSpin)); + Assert.That(body.ResultCode, Is.EqualTo(1)); + } + private static MatchContext FixtureCtx(IReadOnlyList? deck = null) => new( SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(), ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",