Three dispatch arms had Type-based branching that was either wrong or
unnecessary. Unified per the audit doc's recommended order, grounded in
verified facts about each participant's PushAsync.
(1) TurnEndFinal — was branched: PvP broadcast TurnEnd+Judge (wrong on a
game-end signal); Scripted pushed BattleFinish(LifeWin). Unified:
- forward the envelope to other (matches prod TK2 capture
battle-traffic_tk2_regular.ndjson:273 — loser receives TurnEndFinal
from server before BattleFinish)
- push BattleFinish(LifeWin) to from (winner)
- push BattleFinish(LifeLose) to other (loser)
- Phase → Terminal
Requires ScriptedBotParticipant.PushAsync to no longer fire its 3-frame
burst on TurnEndFinal (previously it reacted to both TurnEnd and
TurnEndFinal). The dispatch arm now owns TurnEndFinal's response; the
bot reacting too would race with the BattleFinish push. Bot still
fires on regular TurnEnd as before.
(2) Retire / Kill — was branched: PvP pushed Lose=0 (NotFinish) /
Win=1 (NoContest); Scripted pushed BuildBattleFinishNoContest() (Win=1).
Both shipped wrong RESULT_CODE values; the audit doc's outstanding item
documented this. Unified:
- push BattleFinish(RetireLose=106) to from (the retirer)
- push BattleFinish(RetireWin=105) to other (the survivor)
- Phase → Terminal
Added RetireWin=105 / RetireLose=106 to BattleResult enum with the
same player-perspective convention.
(3) PvP gameplay forwarder (TurnStart / PlayActions / Echo /
TurnEndActions / JudgeResult) — had a redundant `Type == BattleType.Pvp`
guard. Verified that BothAfterReady() is naturally only true when both
participants are RealParticipant (ScriptedBot / NoOpBot don't implement
IHasHandshakePhase per RealParticipant.cs:20-23 / Participants/*.cs grep).
Dropped the redundant guard.
Bot type still has its dedicated InitBattle/Loaded/TurnEnd arms above
the unified ones, so Bot-specific behavior is unchanged.
Tests: 177 battle-node tests passing.
- Updated 9 tests to match the unified dispatch (paired BattleFinish
pushes, correct RESULT_CODE values, forwarded TurnEndFinal envelope).
- ScriptedBotParticipantTests.PushAsync_TurnEndFinal_* rewritten to
assert the bot does NOT fire on TurnEndFinal (was asserting it did).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
403 lines
20 KiB
C#
403 lines
20 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;
|
|
}
|
|
|
|
// 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 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>();
|
|
}
|
|
}
|