refactor(battle-node): delete legacy ComputeFrames switch; dispatch is now lookup-or-drop
This commit is contained in:
@@ -1,7 +1,5 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SVSim.BattleNode.Lifecycle;
|
|
||||||
using SVSim.BattleNode.Protocol;
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Protocol.Bodies;
|
|
||||||
using SVSim.BattleNode.Sessions.Dispatch;
|
using SVSim.BattleNode.Sessions.Dispatch;
|
||||||
using SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
using SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
using SVSim.BattleNode.Sessions.Participants;
|
using SVSim.BattleNode.Sessions.Participants;
|
||||||
@@ -31,8 +29,8 @@ public sealed class BattleSession
|
|||||||
public IBattleParticipant B { get; }
|
public IBattleParticipant B { get; }
|
||||||
public BattleSessionPhase Phase => _state.SessionPhase;
|
public BattleSessionPhase Phase => _state.SessionPhase;
|
||||||
|
|
||||||
// Per-URI handlers consulted before the legacy switch. Populated incrementally (Tasks 5-14);
|
// Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown
|
||||||
// each registered URI is served by its handler and its legacy switch arm goes dead.
|
// URIs are dropped with a LogDebug in ComputeFrames.
|
||||||
private static readonly IReadOnlyDictionary<NetworkBattleUri, IFrameHandler> Handlers = BuildHandlers();
|
private static readonly IReadOnlyDictionary<NetworkBattleUri, IFrameHandler> Handlers = BuildHandlers();
|
||||||
|
|
||||||
private static IReadOnlyDictionary<NetworkBattleUri, IFrameHandler> BuildHandlers()
|
private static IReadOnlyDictionary<NetworkBattleUri, IFrameHandler> BuildHandlers()
|
||||||
@@ -164,234 +162,14 @@ public sealed class BattleSession
|
|||||||
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
|
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
|
||||||
/// standing up real participants.
|
/// standing up real participants.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal IReadOnlyList<DispatchRoute> ComputeFrames(
|
internal IReadOnlyList<DispatchRoute> ComputeFrames(IBattleParticipant from, MsgEnvelope env)
|
||||||
IBattleParticipant from, MsgEnvelope env)
|
|
||||||
{
|
{
|
||||||
if (Handlers.TryGetValue(env.Uri, out var handler))
|
if (Handlers.TryGetValue(env.Uri, out var handler))
|
||||||
return handler.Handle(BuildContext(from, env));
|
return handler.Handle(BuildContext(from, env));
|
||||||
|
|
||||||
// --- legacy switch (shrinking; deleted in the final task) ---
|
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
|
||||||
var result = new List<DispatchRoute>();
|
BattleId, env.Uri, Phase, from.ViewerId);
|
||||||
var other = ReferenceEquals(from, A) ? B : A;
|
return Array.Empty<DispatchRoute>();
|
||||||
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(new DispatchRoute(from, BattleFrames.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(new DispatchRoute(from, BattleFrames.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(new DispatchRoute(from, BattleFrames.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(new DispatchRoute(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(new DispatchRoute(from, ScriptedLifecycle.BuildBattleStart(
|
|
||||||
from.Context, other.Context, from.ViewerId, turnState), false));
|
|
||||||
result.Add(new DispatchRoute(from, ScriptedLifecycle.BuildDeal(), false));
|
|
||||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap:
|
|
||||||
{
|
|
||||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(BattleFrames.ExtractIdxList(env));
|
|
||||||
// SwapResponse is always immediate — it completes the sender's own mulligan UI.
|
|
||||||
result.Add(new DispatchRoute(from, ScriptedLifecycle.BuildSwapResponse(hand), false));
|
|
||||||
_state.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(_state.PostSwapHands.ContainsKey))
|
|
||||||
{
|
|
||||||
foreach (var p in swappers)
|
|
||||||
{
|
|
||||||
var opponent = ReferenceEquals(p, A) ? B : A;
|
|
||||||
var ready = opponent is IHasHandshakePhase && _state.PostSwapHands.TryGetValue(opponent, out var oppoHand)
|
|
||||||
? ScriptedLifecycle.BuildReady(_state.PostSwapHands[p], oppoHand) // both hands known
|
|
||||||
: ScriptedLifecycle.BuildReady(_state.PostSwapHands[p]); // non-interactive opponent
|
|
||||||
result.Add(new DispatchRoute(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 = BattleFrames.BuildTurnEndBroadcast();
|
|
||||||
var judgeBroadcast = BattleFrames.BuildJudgeBroadcast();
|
|
||||||
result.Add(new DispatchRoute(from, turnEndBroadcast, false));
|
|
||||||
result.Add(new DispatchRoute(other, turnEndBroadcast, false));
|
|
||||||
result.Add(new DispatchRoute(from, judgeBroadcast, false));
|
|
||||||
result.Add(new DispatchRoute(other, judgeBroadcast, false));
|
|
||||||
}
|
|
||||||
else if (Type == BattleType.Scripted)
|
|
||||||
{
|
|
||||||
result.Add(new DispatchRoute(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(new DispatchRoute(other, env, false));
|
|
||||||
result.Add(new DispatchRoute(from, BattleFrames.BuildBattleFinish(BattleResult.LifeWin), true));
|
|
||||||
result.Add(new DispatchRoute(other, BattleFrames.BuildBattleFinish(BattleResult.LifeLose), true));
|
|
||||||
_state.SessionPhase = 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(new DispatchRoute(from, BattleFrames.BuildBattleFinish(BattleResult.RetireLose), true));
|
|
||||||
result.Add(new DispatchRoute(other, BattleFrames.BuildBattleFinish(BattleResult.RetireWin), true));
|
|
||||||
_state.SessionPhase = 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(new DispatchRoute(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(new DispatchRoute(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;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user