Files
SVSimServer/SVSim.BattleNode/Sessions/BattleSession.cs
gamer147 51e9dd2094 fix(battle-node): Bot mode must push Matched + BattleStart (client state-machine triggers)
Phase 3 shipped a Bot dispatch table that ack'd InitBattle without
pushing Matched and stayed silent on Loaded, per the architecture spec's
inference that "the client uses AIBattleStart HTTP data instead of
Matched in Bot mode." That inference was wrong.

The client's matching state machine (Matching.ReactionReceiveUri,
Matching.cs:400) gates StartBattleLoad() on the Matched envelope, and
BattleStart at Matching.cs:417 triggers GotoBattle. Without those
envelopes the client never transitions out of MatchingStatus.Connect —
which renders as the "Waiting for opponent" hang on the loading screen.
AIBattleStart HTTP only provides opponent cosmetics, not state-machine
triggers.

Fix: drop the Bot-specific InitBattle ack-only and Loaded silent arms;
let Bot fall through to the existing handshake arms that push Matched
and BattleStart + Deal. Only TurnEnd stays Bot-specific (Judge to
sender, not broadcast — there's no real other side to broadcast to).

Tests updated to match the corrected contract. ai-passive.md doc
amended with a correction note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:56:22 -04:00

358 lines
17 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 the
// Bot-specific TurnEnd wins pattern matching on Type == Bot. InitBattle and
// Loaded fall through to the regular handshake arms below (which push Matched
// and BattleStart+Deal) — the architecture spec's "no Matched in Bot mode"
// claim was wrong: the client's Matching.ReactionReceiveUri (Matching.cs:400)
// gates StartBattleLoad on receiving Matched, and BattleStart triggers
// GotoBattle. Without those envelopes the client hangs in matching status
// Connect ("Waiting for opponent"). AIBattleStart HTTP only provides
// opponent cosmetics, not the state-machine triggers.
//
// Bot's Swap arm reuses the existing Scripted Swap arm (per-sender
// SwapResponse + Ready), and Retire/Kill reuses BattleFinishNoContest.
// Reference: docs/api-spec/in-battle/ai-passive.md.
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>();
}
}