diff --git a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs index 3f958fa..fde884b 100644 --- a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs +++ b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs @@ -53,10 +53,10 @@ public static class ScriptedLifecycle bid: battleId); public static MsgEnvelope BuildBattleStart( - MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId) => + MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId, int turnState) => EnvelopeForPush(NetworkBattleUri.BattleStart, new BattleStartBody( - TurnState: 0, // player goes first + TurnState: turnState, // 0 = this side goes first, 1 = second. Caller decides. BattleType: selfCtx.BattleType, SelfInfo: new BattleStartSelfInfo( Rank: ScriptedProfiles.PlayerRank, diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index a435db4..805e1fd 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -207,11 +207,19 @@ public sealed class BattleSession break; case NetworkBattleUri.Loaded when phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded: + { + // Exactly one side goes first. A goes first deterministically: in Scripted that's + // the real player (constructed as A); in PvP that's the first arriver. No Type + // check — the rule is correct in both modes, and Bot/AINetwork never reaches this + // arm (its silent Loaded arm above wins the match). A per-battle coin-flip is a + // follow-up (see plan § Out of scope). + var turnState = ReferenceEquals(from, A) ? 0 : 1; result.Add((from, ScriptedLifecycle.BuildBattleStart( - from.Context, other.Context, from.ViewerId), false)); + from.Context, other.Context, from.ViewerId, turnState), false)); result.Add((from, ScriptedLifecycle.BuildDeal(), false)); phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap; break; + } case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap: { diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs index d186719..730de6d 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs @@ -71,7 +71,7 @@ public class ScriptedLifecycleTests [Test] public void BuildBattleStart_HasTurnStateZero_AndUsesContextBattleType() { - var env = ScriptedLifecycle.BuildBattleStart(FixtureCtx(), ScriptedBotCtx(), selfViewerId: 1); + var env = ScriptedLifecycle.BuildBattleStart(FixtureCtx(), ScriptedBotCtx(), selfViewerId: 1, turnState: 0); var body = (BattleStartBody)env.Body; Assert.That(body.TurnState, Is.EqualTo(0)); Assert.That(body.BattleType, Is.EqualTo(11)); @@ -87,7 +87,7 @@ public class ScriptedLifecycleTests BattleType = 42, }; - var env = ScriptedLifecycle.BuildBattleStart(ctx, ScriptedBotCtx(), selfViewerId: 1); + var env = ScriptedLifecycle.BuildBattleStart(ctx, ScriptedBotCtx(), selfViewerId: 1, turnState: 0); var body = (BattleStartBody)env.Body; Assert.That(body.SelfInfo.ClassId, Is.EqualTo("7")); diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs index 1c37910..11c7fb1 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs @@ -86,7 +86,7 @@ public class TypedBodyWireShapeTests [Test] public void BuildBattleStart_SerializesAllWireKeysAndPreservesBattlePointAsymmetry() { - var env = ScriptedLifecycle.BuildBattleStart(FixtureCtx(), ScriptedBotCtx(), selfViewerId: 906243102); + var env = ScriptedLifecycle.BuildBattleStart(FixtureCtx(), ScriptedBotCtx(), selfViewerId: 906243102, turnState: 0); var json = MsgEnvelope.ToJson(env); var node = JsonNode.Parse(json)!.AsObject(); diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 598ff7a..031212b 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -52,6 +52,43 @@ public class BattleSessionDispatchTests Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap)); } + [Test] + public void Pvp_Loaded_from_A_assigns_turnState_0() + { + var (s, a, _) = NewPvpSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + + var bs = (BattleStartBody)routes[0].Frame.Body; + Assert.That(bs.TurnState, Is.EqualTo(0), "A (first arriver) goes first."); + } + + [Test] + public void Pvp_Loaded_from_B_assigns_turnState_1() + { + var (s, _, b) = NewPvpSession(); + s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle)); + var routes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Loaded)); + + var bs = (BattleStartBody)routes[0].Frame.Body; + Assert.That(bs.TurnState, Is.EqualTo(1), "B (second arriver) goes second."); + } + + [Test] + public void Scripted_Loaded_from_player_assigns_turnState_0() + { + // Real player is constructed as A in scripted sessions, so it always goes first. + var (s, a, _) = NewSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + + var bs = (BattleStartBody)routes[0].Frame.Body; + Assert.That(bs.TurnState, Is.EqualTo(0)); + } + [Test] public void Swap_pushes_SwapResponse_then_Ready_to_sender() {