Ready was sent per-side immediately carrying the placeholder opponent hand, so one client cleared mulligan before the other. The barrier now releases Ready to every IHasHandshakePhase participant only once all have swapped, each carrying the opponent's real post-mulligan hand. No Type check — NoOp (Bot/AINetwork) isn't a phase impl, so that mode still releases immediately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
429 lines
22 KiB
C#
429 lines
22 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;
|
|
|
|
// Mulligan barrier: each side's post-mulligan hand, captured on its Swap. Ready is
|
|
// withheld until every IHasHandshakePhase participant has swapped (prod withholds it
|
|
// ~3-6 s — see data_dumps/captures/battle-traffic_tk2_*.ndjson). Keyed on the
|
|
// IBattleParticipant so the per-side hand is retrievable when releasing both Readys.
|
|
// NOTE: mutated from ComputeFrames, which is not lock-guarded — same pre-existing
|
|
// single-threaded-dispatch assumption as the Phase mutations (see plan § Out of scope).
|
|
private readonly Dictionary<IBattleParticipant, long[]> _postSwapHands = new();
|
|
|
|
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(DisconnectWin)
|
|
// to survivor. DisconnectWin=201 → client renders "opponent disconnected" →
|
|
// WIN UI; the legacy Win=1 used here previously rendered "no contest".
|
|
try
|
|
{
|
|
await survivor.PushAsync(
|
|
BuildBattleFinish(BattleResult.DisconnectWin), 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:
|
|
{
|
|
// Exactly one side goes first. A goes first deterministically: in Scripted that's
|
|
// the real player (constructed as A); in PvP that's the first arriver. No Type
|
|
// check — the rule is correct in both modes, and Bot/AINetwork never reaches this
|
|
// arm (its silent Loaded arm above wins the match). A per-battle coin-flip is a
|
|
// follow-up (see plan § Out of scope).
|
|
var turnState = ReferenceEquals(from, A) ? 0 : 1;
|
|
result.Add((from, ScriptedLifecycle.BuildBattleStart(
|
|
from.Context, other.Context, from.ViewerId, turnState), 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));
|
|
// SwapResponse is always immediate — it completes the sender's own mulligan UI.
|
|
result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false));
|
|
_postSwapHands[from] = hand;
|
|
phaseFrom!.Phase = BattleSessionPhase.AfterReady;
|
|
|
|
// Release Ready to every swapper once all handshake-driving participants have
|
|
// swapped. IHasHandshakePhase membership IS the "participates in mulligan" set:
|
|
// PvP → {A, B} (both reals) → waits for both
|
|
// Scripted → {player, bot} (bot now emits Swap) → waits for both
|
|
// Bot/AINet → {real} only (NoOp isn't a phase impl)→ releases on the one Swap
|
|
var swappers = new[] { A, B }.Where(p => p is IHasHandshakePhase).ToList();
|
|
if (swappers.All(_postSwapHands.ContainsKey))
|
|
{
|
|
foreach (var p in swappers)
|
|
{
|
|
var opponent = ReferenceEquals(p, A) ? B : A;
|
|
var ready = opponent is IHasHandshakePhase && _postSwapHands.TryGetValue(opponent, out var oppoHand)
|
|
? ScriptedLifecycle.BuildReady(_postSwapHands[p], oppoHand) // both hands known
|
|
: ScriptedLifecycle.BuildReady(_postSwapHands[p]); // non-interactive opponent
|
|
result.Add((p, ready, false));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Regular TurnEnd: continues the game. Scripted forwards to bot for the 3-frame
|
|
// burst; PvP broadcasts; Bot stays silent.
|
|
case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
|
if (Type == BattleType.Pvp && BothAfterReady())
|
|
{
|
|
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)
|
|
{
|
|
result.Add((other, env, false));
|
|
}
|
|
// Bot type: no-op (NoOpBot swallows; client handles its own turn end).
|
|
break;
|
|
|
|
// TurnEndFinal: client signals the player's FINAL turn is over (game-end
|
|
// condition met, usually killed opponent's leader). Unified across types:
|
|
// forward the envelope to other (matches prod TK2 capture
|
|
// battle-traffic_tk2_regular.ndjson:273 — loser-side receives TurnEndFinal
|
|
// from server before BattleFinish), then push BattleFinish per-side with
|
|
// player-perspective codes (LifeWin to winner, LifeLose to loser).
|
|
// ScriptedBotParticipant no longer reacts to TurnEndFinal (only TurnEnd) —
|
|
// this dispatch arm owns it. NoOpBotParticipant swallows. Phase → Terminal
|
|
// so the RunAsync cascade doesn't synthesize a follow-up BattleFinish.
|
|
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
|
result.Add((other, env, false));
|
|
result.Add((from, BuildBattleFinish(BattleResult.LifeWin), true));
|
|
result.Add((other, BuildBattleFinish(BattleResult.LifeLose), true));
|
|
Phase = BattleSessionPhase.Terminal;
|
|
break;
|
|
|
|
// Retire / Kill: sender concedes (Retire) or the client requested an immediate
|
|
// terminate (Kill). Unified across types: push BattleFinish per-side with the
|
|
// proper retire codes. Bots swallow their push (no real-opponent state).
|
|
case NetworkBattleUri.Retire:
|
|
case NetworkBattleUri.Kill:
|
|
result.Add((from, BuildBattleFinish(BattleResult.RetireLose), true));
|
|
result.Add((other, BuildBattleFinish(BattleResult.RetireWin), 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;
|
|
|
|
// Gameplay-frame forwarding (post-AfterReady). Unified across types:
|
|
// BothAfterReady() is only true when both participants are RealParticipants
|
|
// (ScriptedBot/NoOpBot don't implement IHasHandshakePhase so their Phase is
|
|
// always null), so this arm naturally fires for PvP only. Order matters:
|
|
// this MUST come after the FakeOpponentViewerId arms so Scripted bot
|
|
// emissions don't fall into this forwarder.
|
|
case NetworkBattleUri.TurnStart when BothAfterReady():
|
|
case NetworkBattleUri.PlayActions when BothAfterReady():
|
|
case NetworkBattleUri.Echo when BothAfterReady():
|
|
case NetworkBattleUri.TurnEndActions when BothAfterReady():
|
|
case NetworkBattleUri.JudgeResult when 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 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>();
|
|
}
|
|
}
|