From e06d97ef6fa5d2538427357d4ffbf67c8fff44bb Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 1 Jun 2026 02:08:04 -0400 Subject: [PATCH] fix(battle-node): respond to InitBattle/Loaded, not InitNetwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushing Matched in response to InitNetwork lands it before MatchingInitBattle() finishes wiring up the OnReceivedEvent handler and setting status=Connect. The client's Matched-case in ReactionReceiveUri only transitions to StartLoad when status is Connect at the moment of receipt; otherwise the frame is silently dropped at the state machine and the matchmaking UI never advances. The real connect-handshake sequence (per MatchingNetworkConnectChecker + Matching.cs): 1. WS opens. 2. Client emits InitNetwork (cat=general). 3. Server replies InitNetwork ack → _initNetworkSuccess = true. 4. MatchingInitBattle: status=Connect; emit InitBattle; subscribe OnReceivedEvent matching handler. 5. Server replies Matched → status=StartLoad, StartBattleLoad. 6. Asset load done → client emits Loaded. 7. Server replies BattleStart + Deal → status=Prepared, GotoBattle. Add AwaitingInitBattle phase, gate Matched on InitBattle receipt, and gate BattleStart+Deal on Loaded receipt. Update dispatch and integration tests to walk the new sequence; InitBattle's wire cat is Matching(2), not Battle(1). Caught during v1 smoke walkthrough — battle-traffic.ndjson showed the client receiving Matched/BattleStart at sub-millisecond gaps after InitNetwork ack, but never advancing past matchmaking. Co-Authored-By: Claude Opus 4.7 --- SVSim.BattleNode/Sessions/BattleSession.cs | 17 +++++++++++- .../Sessions/BattleSessionPhase.cs | 1 + .../Integration/BattleNodeFlowTests.cs | 27 ++++++++++--------- .../Sessions/BattleSessionDispatchTests.cs | 23 ++++++++++++---- 4 files changed, 50 insertions(+), 18 deletions(-) diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 1c3d086..ad7c263 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -183,13 +183,28 @@ public sealed class BattleSession var result = new List<(MsgEnvelope Envelope, bool NoStock)>(); switch (env.Uri) { + // The real handshake sequence (MatchingNetworkConnectChecker + Matching.cs): + // 1. WS opens. + // 2. Client emits InitNetwork (cat=general). + // 3. Server replies with InitNetwork ack → _initNetworkSuccess = true. + // 4. MatchingInitBattle() runs: status=Connect, emits InitBattle, THEN subscribes + // the OnReceivedEvent matching handler. + // 5. Server replies with Matched → handler is subscribed, status=Connect → + // transitions to StartLoad and StartBattleLoad() loads decks/scene. + // 6. Asset load completes → client emits Loaded. + // 7. Server replies with BattleStart + Deal → status=Prepared, GotoBattle(). + // Pushing Matched in response to InitNetwork (instead of InitBattle) drops it + // before the handler is subscribed; the state machine never advances. case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork: result.Add((BuildAckedEnvelope(NetworkBattleUri.InitNetwork), NoStock: true)); + Phase = BattleSessionPhase.AwaitingInitBattle; + break; + case NetworkBattleUri.InitBattle when Phase == BattleSessionPhase.AwaitingInitBattle: result.Add((ScriptedLifecycle.BuildMatched(ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: false)); - result.Add((ScriptedLifecycle.BuildBattleStart(ViewerId), NoStock: false)); Phase = BattleSessionPhase.AwaitingLoaded; break; case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded: + result.Add((ScriptedLifecycle.BuildBattleStart(ViewerId), NoStock: false)); result.Add((ScriptedLifecycle.BuildDeal(), NoStock: false)); Phase = BattleSessionPhase.AwaitingSwap; break; diff --git a/SVSim.BattleNode/Sessions/BattleSessionPhase.cs b/SVSim.BattleNode/Sessions/BattleSessionPhase.cs index 8e4abe1..3e33b6d 100644 --- a/SVSim.BattleNode/Sessions/BattleSessionPhase.cs +++ b/SVSim.BattleNode/Sessions/BattleSessionPhase.cs @@ -7,6 +7,7 @@ namespace SVSim.BattleNode.Sessions; public enum BattleSessionPhase { AwaitingInitNetwork, + AwaitingInitBattle, AwaitingLoaded, AwaitingSwap, AfterReady, diff --git a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs index 2d58e42..df99569 100644 --- a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs @@ -40,21 +40,21 @@ public class BattleNodeFlowTests await using var client = new RawSocketIoTestClient(ws); await client.ConsumeHandshakeAsync(ct); - // 1. InitNetwork → expect InitNetwork ack push, then Matched, then BattleStart. + // 1. InitNetwork → expect InitNetwork ack push only. await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct); - var f1 = await client.ReceiveSynchronizeAsync(ct); - var f2 = await client.ReceiveSynchronizeAsync(ct); - var f3 = await client.ReceiveSynchronizeAsync(ct); - Assert.That(f1.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); - Assert.That(f2.Uri, Is.EqualTo(NetworkBattleUri.Matched)); - Assert.That(f3.Uri, Is.EqualTo(NetworkBattleUri.BattleStart)); + Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); - // 2. Loaded → expect Deal. - await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Loaded, pubSeq: 2), key, ct); + // 2. InitBattle → expect Matched (handler is now subscribed on the client side). + await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitBattle, pubSeq: 2), key, ct); + Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Matched)); + + // 3. Loaded → expect BattleStart + Deal. + await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Loaded, pubSeq: 3), key, ct); + Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.BattleStart)); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Deal)); - // 3. Swap with empty idxList → expect Swap response + Ready. - await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Swap, pubSeq: 3, + // 4. Swap with empty idxList → expect Swap response + Ready. + await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Swap, pubSeq: 4, body: new Dictionary { ["idxList"] = new List() }), key, ct); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Swap)); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Ready)); @@ -62,7 +62,10 @@ public class BattleNodeFlowTests private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq, Dictionary? body = null) => new(uri, ViewerId: 906243102, Uuid: "udid-test", Bid: null, Try: 0, - Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General : EmitCategory.Battle, + // EmitMsgPack: InitNetwork → general(99); other matching URIs → matching(2); else battle(1). + Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General + : uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching + : EmitCategory.Battle, PubSeq: pubSeq, PlaySeq: null, Body: body ?? new Dictionary()); private static string MakeKey() diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 1297d95..8d4292c 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -20,22 +20,34 @@ public class BattleSessionDispatchTests PubSeq: null, PlaySeq: null, Body: new Dictionary()); [Test] - public void InitNetwork_PushesAckThenMatchedThenBattleStart() + public void InitNetwork_PushesAckOnly_TransitionsToAwaitingInitBattle() { var s = NewSession(); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); Assert.That(responses.Select(r => r.Envelope.Uri), - Is.EqualTo(new[] { NetworkBattleUri.InitNetwork, NetworkBattleUri.Matched, NetworkBattleUri.BattleStart })); + Is.EqualTo(new[] { NetworkBattleUri.InitNetwork })); + Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle)); + } + + [Test] + public void InitBattle_PushesMatched_TransitionsToAwaitingLoaded() + { + var s = NewSession(); + s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); + var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); + Assert.That(responses.Single().Envelope.Uri, Is.EqualTo(NetworkBattleUri.Matched)); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); } [Test] - public void Loaded_PushesDeal_TransitionsToAwaitingSwap() + public void Loaded_PushesBattleStartThenDeal_TransitionsToAwaitingSwap() { var s = NewSession(); - s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); // advance phase + s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); - Assert.That(responses.Single().Envelope.Uri, Is.EqualTo(NetworkBattleUri.Deal)); + Assert.That(responses.Select(r => r.Envelope.Uri), + Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal })); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap)); } @@ -44,6 +56,7 @@ public class BattleSessionDispatchTests { var s = NewSession(); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); Assert.That(responses.Select(r => r.Envelope.Uri),