fix(battle-node): respond to InitBattle/Loaded, not InitNetwork

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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-01 02:08:04 -04:00
parent 0b859f1c8e
commit e06d97ef6f
4 changed files with 50 additions and 18 deletions

View File

@@ -183,13 +183,28 @@ public sealed class BattleSession
var result = new List<(MsgEnvelope Envelope, bool NoStock)>(); var result = new List<(MsgEnvelope Envelope, bool NoStock)>();
switch (env.Uri) 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: case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork:
result.Add((BuildAckedEnvelope(NetworkBattleUri.InitNetwork), NoStock: true)); 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.BuildMatched(ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: false));
result.Add((ScriptedLifecycle.BuildBattleStart(ViewerId), NoStock: false));
Phase = BattleSessionPhase.AwaitingLoaded; Phase = BattleSessionPhase.AwaitingLoaded;
break; break;
case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded: case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded:
result.Add((ScriptedLifecycle.BuildBattleStart(ViewerId), NoStock: false));
result.Add((ScriptedLifecycle.BuildDeal(), NoStock: false)); result.Add((ScriptedLifecycle.BuildDeal(), NoStock: false));
Phase = BattleSessionPhase.AwaitingSwap; Phase = BattleSessionPhase.AwaitingSwap;
break; break;

View File

@@ -7,6 +7,7 @@ namespace SVSim.BattleNode.Sessions;
public enum BattleSessionPhase public enum BattleSessionPhase
{ {
AwaitingInitNetwork, AwaitingInitNetwork,
AwaitingInitBattle,
AwaitingLoaded, AwaitingLoaded,
AwaitingSwap, AwaitingSwap,
AfterReady, AfterReady,

View File

@@ -40,21 +40,21 @@ public class BattleNodeFlowTests
await using var client = new RawSocketIoTestClient(ws); await using var client = new RawSocketIoTestClient(ws);
await client.ConsumeHandshakeAsync(ct); 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); await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
var f1 = await client.ReceiveSynchronizeAsync(ct); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
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));
// 2. Loaded → expect Deal. // 2. InitBattle → expect Matched (handler is now subscribed on the client side).
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Loaded, pubSeq: 2), key, ct); 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)); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Deal));
// 3. Swap with empty idxList → expect Swap response + Ready. // 4. Swap with empty idxList → expect Swap response + Ready.
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Swap, pubSeq: 3, await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Swap, pubSeq: 4,
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct); body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Swap)); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Swap));
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Ready)); 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<string, object?>? body = null) => private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq, Dictionary<string, object?>? body = null) =>
new(uri, ViewerId: 906243102, Uuid: "udid-test", Bid: null, Try: 0, 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<string, object?>()); PubSeq: pubSeq, PlaySeq: null, Body: body ?? new Dictionary<string, object?>());
private static string MakeKey() private static string MakeKey()

View File

@@ -20,22 +20,34 @@ public class BattleSessionDispatchTests
PubSeq: null, PlaySeq: null, Body: new Dictionary<string, object?>()); PubSeq: null, PlaySeq: null, Body: new Dictionary<string, object?>());
[Test] [Test]
public void InitNetwork_PushesAckThenMatchedThenBattleStart() public void InitNetwork_PushesAckOnly_TransitionsToAwaitingInitBattle()
{ {
var s = NewSession(); var s = NewSession();
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
Assert.That(responses.Select(r => r.Envelope.Uri), 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)); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
} }
[Test] [Test]
public void Loaded_PushesDeal_TransitionsToAwaitingSwap() public void Loaded_PushesBattleStartThenDeal_TransitionsToAwaitingSwap()
{ {
var s = NewSession(); 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)); 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)); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap));
} }
@@ -44,6 +56,7 @@ public class BattleSessionDispatchTests
{ {
var s = NewSession(); var s = NewSession();
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded));
var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap));
Assert.That(responses.Select(r => r.Envelope.Uri), Assert.That(responses.Select(r => r.Envelope.Uri),