Files
SVSimServer/SVSim.BattleNode/Sessions/BattleSession.cs
gamer147 8aead62116 fix(battle-node): revert Bot Matched/BattleStart push (corrupts OppoBattleStartInfo)
Previous commit 51e9dd2 changed Bot's InitBattle to push Matched and
Loaded to push BattleStart+Deal, on the theory that the architecture
spec's "no Matched in Bot mode" claim was wrong. That theory was based
on misreading Matching.cs:400 (the Matched handler) as a required
state-machine trigger.

End-to-end trace of the AI client flow shows:

  1. _initNetworkSuccess (set when the client receives uri=InitNetwork,
     i.e., our ack) is the actual trigger — MatchingNetworkConnectChecker
     phase 3 sees it and calls MatchingInitBattle.

  2. MatchingInitBattle (Matching.cs:298) for IsAINetwork IMMEDIATELY
     calls StartBattleLoad + GotoBattle right after emitting InitBattle.
     It does NOT wait for any wire envelope.

  3. The Matched handler at Matching.cs:400 is gated on
     status == Connect and is already past Prepared by the time the
     wire round-trip completes — sending Matched is harmless but
     unnecessary.

  4. The BattleStart handler at Matching.cs:417 runs UNCONDITIONALLY and
     SetNetworkInfo at RealTimeNetworkAgent.cs:1562 overwrites
     OppoBattleStartInfo with the wire envelope's oppoInfo. Our oppoInfo
     comes from NoOpBotParticipant.Context placeholders (classId/emblemId
     etc. = 0), corrupting the good values the client set from the HTTP
     AIBattleStart response.

The "Waiting for opponent" hang was caused by SBattleLoad.LoadOpponentAssets
trying to fetch emblemId=0, degreeId=0, etc. after BattleStart corrupted
OppoBattleStartInfo. The asset group load silently hangs on missing
assets, no error logged.

Restored the spec's original Bot arms: InitBattle ack-only, Loaded silent,
TurnEnd Judge-to-sender. ai-passive.md updated with the corrected reasoning
and a discovery-history note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:23:13 -04:00

384 lines
18 KiB
C#

using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
using SVSim.BattleNode.Sessions.Participants;
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// v2 broker session. Holds two participants and brokers between them. Subscribes
/// to each participant's <see cref="IBattleParticipant.FrameEmitted"/>; on each frame,
/// runs <see cref="ComputeFrames"/> to determine the routing (target + frame + noStock
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
/// </summary>
/// <remarks>
/// Phase 1 wires this for <see cref="BattleType.Scripted"/> only — the dispatch logic
/// preserves v1.2 behaviour. Phase 2 wires Pvp (broadcast Matched/BattleStart per-perspective,
/// forward gameplay frames between participants). Phase 3 wires Bot (ack-only).
/// </remarks>
public sealed class BattleSession
{
private readonly ILogger<BattleSession> _log;
public string BattleId { get; }
public BattleType Type { get; }
public IBattleParticipant A { get; }
public IBattleParticipant B { get; }
public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork;
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
ILogger<BattleSession> log)
{
BattleId = battleId;
Type = type;
A = a;
B = b;
_log = log;
// Subscribe to both participants' emissions.
A.FrameEmitted += OnFrameFromA;
B.FrameEmitted += OnFrameFromB;
}
public async Task RunAsync(CancellationToken cancellation)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var aTask = A.RunAsync(cts.Token);
var bTask = B.RunAsync(cts.Token);
if (Type == BattleType.Pvp)
{
// WhenAny: first WS drop / first graceful close triggers cascade.
// ScriptedBotParticipant.RunAsync also returns immediately; that's not used
// here (Pvp has two RealParticipants), but we'd still want a synthesized
// BattleFinish for the survivor if either side terminates first.
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
var survivor = first == aTask ? B : A;
if (Phase != BattleSessionPhase.Terminal)
{
// Involuntary drop (no graceful Retire): synthesize BattleFinish(Win) to survivor.
try
{
await survivor.PushAsync(
BuildBattleFinish(BattleResult.Win), noStock: true, cancellation)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_log.LogWarning(ex,
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
BattleId);
}
Phase = BattleSessionPhase.Terminal;
}
cts.Cancel(); // unblock the survivor's RunAsync read loop
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
catch { /* swallow cancellation / WS exceptions */ }
}
else
{
// Phase 1 semantics for Scripted/Bot: wait for ALL participants. The bot's
// RunAsync returns immediately; the session keeps running for the real one.
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
catch { /* swallow */ }
}
await Task.WhenAll(
A.TerminateAsync(BattleFinishReason.NormalFinish),
B.TerminateAsync(BattleFinishReason.NormalFinish))
.ConfigureAwait(false);
}
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
private Task OnFrameFromB(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(B, env, ct);
private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct)
{
try
{
var routes = ComputeFrames(from, env);
foreach (var (target, frame, noStock) in routes)
{
await target.PushAsync(frame, noStock, ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId);
}
}
/// <summary>
/// Pure-logic dispatch: given an inbound frame from one participant, return the list
/// of (target, frame, noStock) tuples the session should dispatch. Transitions
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
/// standing up real participants.
/// </summary>
internal IReadOnlyList<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames(
IBattleParticipant from, MsgEnvelope env)
{
var result = new List<(IBattleParticipant, MsgEnvelope, bool)>();
var other = ReferenceEquals(from, A) ? B : A;
var phaseFrom = from as IHasHandshakePhase;
// The dispatch table only covers the Scripted-mode behaviour Phase 1 needs;
// Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches. Handshake-phase
// arms read the SENDER's Phase (per-participant); the session-level Phase
// remains only for the Terminal short-circuit.
switch (env.Uri)
{
case NetworkBattleUri.InitNetwork when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitNetwork:
result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true));
phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle;
break;
// --- Phase 3 Bot arms — placed BEFORE the existing handshake arms so they
// win pattern matching on Type == Bot. Bot mode: ack handshake, silent
// Loaded, Judge-to-sender on TurnEnd. The rest reuse Scripted's arms
// (Retire/Kill → BattleFinishNoContest, Swap → per-sender response,
// default → drop). Reference: docs/api-spec/in-battle/ai-passive.md.
//
// Critically, do NOT push Matched or BattleStart for Bot mode. The
// architecture spec was right about this:
// 1. The client's MatchingInitBattle (Matching.cs:298) immediately calls
// StartBattleLoad + GotoBattle on the IsAINetwork branch right after
// emitting InitBattle — it does NOT wait for a wire Matched or
// BattleStart envelope. The state-machine trigger is _initNetworkSuccess
// (set when InitNetwork uri is received, i.e., our ack).
// 2. Sending Matched is harmless (gated on status == Connect, which is
// already past by the time the wire round-trip completes).
// 3. Sending BattleStart is ACTIVELY HARMFUL: its handler at
// Matching.cs:417 runs unconditionally and SetNetworkInfo
// (RealTimeNetworkAgent.cs:1553-1564) overwrites OppoBattleStartInfo
// with the wire envelope's oppoInfo. Our oppoInfo comes from
// NoOpBotParticipant.Context placeholders (classId:0, emblemId:0,
// etc.), corrupting the good values the client just set from the
// HTTP /ai_<fmt>_rank_battle/start response — subsequent asset
// loads (LoadOpponentAssets at SBattleLoad.cs:933) then look up
// non-existent assets and silently hang on "Waiting for opponent."
case NetworkBattleUri.InitBattle
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
// Ack only — NO Matched push.
result.Add((from, BuildAck(NetworkBattleUri.InitBattle), true));
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
break;
case NetworkBattleUri.Loaded
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
// Silent — no BattleStart, no Deal. The client's AINetworkBattleManager
// populates opponent state from AIBattleStart HTTP data; pushing
// BattleStart here overwrites that state with zeros.
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
break;
case NetworkBattleUri.TurnEnd
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
case NetworkBattleUri.TurnEndFinal
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
// Judge to sender ONLY (not broadcast — there's no real other side).
// The client's JudgeOperation → ControlTurnStartPlayer flips back to
// the local AI's turn after this Judge arrives.
result.Add((from, BuildJudgeBroadcast(), false));
break;
case NetworkBattleUri.InitBattle when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
// Phase 1: push Matched only to the "real" participant. The session reads
// selfInfo from from.Context and oppoInfo from other.Context (the scripted
// bot's Context fixture preserves the prod-captured cosmetics that previously
// lived in ScriptedProfiles).
result.Add((from, ScriptedLifecycle.BuildMatched(
from.Context, other.Context,
from.ViewerId, other.ViewerId,
BattleId, ScriptedProfiles.BattleSeed), false));
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
break;
case NetworkBattleUri.Loaded when phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
result.Add((from, ScriptedLifecycle.BuildBattleStart(
from.Context, other.Context, from.ViewerId), false));
result.Add((from, ScriptedLifecycle.BuildDeal(), false));
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
break;
case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap:
{
var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env));
result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false));
result.Add((from, ScriptedLifecycle.BuildReady(hand), false));
phaseFrom!.Phase = BattleSessionPhase.AfterReady;
break;
}
case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
if (Type == BattleType.Pvp && BothAfterReady())
{
// Broadcast TurnEnd + Judge to BOTH. Each client's JudgeOperation ->
// ControlTurnStartPlayer advances the active-player state machine.
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)
{
// Phase 1 Scripted: forward to bot; bot fires three-frame burst back.
result.Add((other, env, false));
}
// For Bot type, no-op (NoOpBot swallows; client handles its own turn end).
break;
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));
}
Phase = BattleSessionPhase.Terminal;
break;
// Frames emitted by the scripted bot (TurnStart / TurnEnd / Judge) — forward
// to the real participant. These match the v1.2 burst's three outbound pushes.
// Pre-migration this arm only handled TurnStart/Judge because the handshake
// TurnEnd arm above (gated on session-level Phase) also caught the bot's TurnEnd.
// Post-migration that arm gates on the sender's per-participant Phase, which the
// bot doesn't have, so the bot's TurnEnd now lands here.
// The `IsRealForwardableFromScripted` guard ensures this arm matches ONLY the
// scripted bot's emissions (sender ViewerId == FakeOpponentViewerId) — without
// it, a TurnStart/TurnEnd/Judge from a real participant in PvP mode would match
// here and `goto default` would skip the PvP forwarder arm below.
case NetworkBattleUri.TurnStart when IsRealForwardableFromScripted(from, env):
case NetworkBattleUri.TurnEnd when IsRealForwardableFromScripted(from, env):
case NetworkBattleUri.Judge when IsRealForwardableFromScripted(from, env):
// Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart,
// TurnEnd, and Judge are intended for the real participant.
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():
result.Add((other, env, false));
break;
default:
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
BattleId, env.Uri, Phase, from.ViewerId);
break;
}
return result;
}
// Phase 1: the only "scripted-bot" emissions we need to forward are the three burst
// frames (TurnStart, TurnEnd, Judge) — and TurnEnd is already handled in the switch
// above as a forwardable bot emission. This helper exists so the TurnStart/Judge cases
// above only fire when the source is actually a participant (not malformed inbound).
private static bool IsRealForwardableFromScripted(IBattleParticipant from, MsgEnvelope env)
{
// The bot's emitted frames carry ViewerId == FakeOpponentViewerId.
return from.ViewerId == ScriptedLifecycle.FakeOpponentViewerId;
}
// Phase 2: PvP gameplay-frame forwarding is gated on BOTH sides having completed
// the handshake (i.e. reached AfterReady). Until then, an early TurnStart/PlayActions
// from one side has no valid recipient.
private bool BothAfterReady() =>
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady &&
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady;
private MsgEnvelope BuildAck(NetworkBattleUri uri) => new(
uri,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.General,
PubSeq: null,
PlaySeq: null,
Body: new ResultCodeOnlyBody());
private MsgEnvelope BuildBattleFinishNoContest() => new(
NetworkBattleUri.BattleFinish,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new BattleFinishBody(Result: BattleResult.Win));
private MsgEnvelope BuildTurnEndBroadcast() => new(
NetworkBattleUri.TurnEnd,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new TurnEndBody(TurnState: 0));
private MsgEnvelope BuildJudgeBroadcast() => new(
NetworkBattleUri.Judge,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin));
private MsgEnvelope BuildBattleFinish(BattleResult result) => new(
NetworkBattleUri.BattleFinish,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new BattleFinishBody(Result: result));
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
{
if (env.Body is not RawBody rawBody) return Array.Empty<long>();
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
{
var result = new List<long>();
foreach (var item in seq)
{
switch (item)
{
case long l: result.Add(l); break;
case int i: result.Add(i); break;
case double d: result.Add((long)d); break;
case decimal m: result.Add((long)m); break;
case string s when long.TryParse(s, out var p): result.Add(p); break;
}
}
return result;
}
return Array.Empty<long>();
}
}