Closes audit Md11. BattleSession.RunAsync now clears each RealParticipant.Outbound archive immediately before the TerminateAsync cascade, releasing the heavy dict the moment the battle ends instead of waiting for the participant to be GC'd. Bots (NoOp / Scripted) don't expose an OutboundSequencer, so the 'p is RealParticipant rp' conditional cast is the natural filter. Tests: 1 new BattleSessionTerminateCascadeTests — pre-load the archive, drive RunAsync to completion via TestWebSocket.CompleteIncoming, assert the archive is empty. Suite: 939 → 948. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
391 lines
19 KiB
C#
391 lines
19 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 */ }
|
|
}
|
|
|
|
// Audit Md11 — release per-participant outbound archives at battle-end
|
|
// (only RealParticipant has one; bots don't archive). Heavy state is
|
|
// dropped synchronously here so the participant's TerminateAsync doesn't
|
|
// need to keep the dict alive through its disposal handshake.
|
|
if (A is RealParticipant rpA) rpA.Outbound.Clear();
|
|
if (B is RealParticipant rpB) rpB.Outbound.Clear();
|
|
|
|
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>();
|
|
}
|
|
}
|