feat(battle-node): add BattleSessionV2 broker (unused yet)
Parallel to existing BattleSession. Subscribes to both participants' FrameEmitted, dispatches via ComputeFrames(from, env) returning (target, frame, noStock) routing tuples. Dispatch table currently only covers Scripted-mode behaviour (preserves v1.2). Phase 2 adds Pvp arms; Phase 3 adds Bot. Not yet wired into the handler — Task 9 cuts over.
This commit is contained in:
210
SVSim.BattleNode/Sessions/BattleSessionV2.cs
Normal file
210
SVSim.BattleNode/Sessions/BattleSessionV2.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
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 BattleSessionV2
|
||||
{
|
||||
private readonly ILogger<BattleSessionV2> _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 BattleSessionV2(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
|
||||
ILogger<BattleSessionV2> 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)
|
||||
{
|
||||
// Run both participants' inbound loops in parallel. First to complete cancels
|
||||
// the session via the outer cancellation token.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
var aTask = A.RunAsync(cts.Token);
|
||||
var bTask = B.RunAsync(cts.Token);
|
||||
await Task.WhenAny(aTask, bTask);
|
||||
cts.Cancel();
|
||||
try { await Task.WhenAll(aTask, bTask); } catch { /* swallow cancellation */ }
|
||||
|
||||
await Task.WhenAll(
|
||||
A.TerminateAsync(BattleFinishReason.NormalFinish),
|
||||
B.TerminateAsync(BattleFinishReason.NormalFinish));
|
||||
}
|
||||
|
||||
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, "BattleSessionV2 {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;
|
||||
|
||||
// The dispatch table only covers the Scripted-mode behaviour Phase 1 needs;
|
||||
// Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches.
|
||||
switch (env.Uri)
|
||||
{
|
||||
case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork:
|
||||
result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true));
|
||||
Phase = BattleSessionPhase.AwaitingInitBattle;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.InitBattle when Phase == BattleSessionPhase.AwaitingInitBattle:
|
||||
// Phase 1: push Matched only to the "real" participant. The session reads
|
||||
// selfInfo from from.Context; opponent half currently comes from
|
||||
// ScriptedProfiles inside ScriptedLifecycle.BuildMatched (Phase 2 generalises
|
||||
// to use other.Context for per-perspective Matched).
|
||||
result.Add((from, ScriptedLifecycle.BuildMatched(from.Context, from.ViewerId, other.ViewerId, BattleId), false));
|
||||
Phase = BattleSessionPhase.AwaitingLoaded;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded:
|
||||
result.Add((from, ScriptedLifecycle.BuildBattleStart(from.Context, from.ViewerId), false));
|
||||
result.Add((from, ScriptedLifecycle.BuildDeal(), false));
|
||||
Phase = BattleSessionPhase.AwaitingSwap;
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Swap when Phase == BattleSessionPhase.AwaitingSwap:
|
||||
{
|
||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env));
|
||||
result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false));
|
||||
result.Add((from, ScriptedLifecycle.BuildReady(hand), false));
|
||||
Phase = BattleSessionPhase.AfterReady;
|
||||
break;
|
||||
}
|
||||
|
||||
case NetworkBattleUri.TurnEnd when Phase == BattleSessionPhase.AfterReady:
|
||||
case NetworkBattleUri.TurnEndFinal when Phase == BattleSessionPhase.AfterReady:
|
||||
// Phase 1: forward the player's TurnEnd to the scripted bot. The bot's
|
||||
// PushAsync fires its three-frame burst via FrameEmitted; each emitted
|
||||
// frame loops back through HandleFrameAsync → ComputeFrames → routes to
|
||||
// the real participant. Net wire effect: same three pushes as v1.2.
|
||||
result.Add((other, env, false));
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Retire:
|
||||
case NetworkBattleUri.Kill:
|
||||
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.
|
||||
case NetworkBattleUri.TurnStart when ReferenceEquals(from, B) || ReferenceEquals(from, A):
|
||||
case NetworkBattleUri.Judge when ReferenceEquals(from, B) || ReferenceEquals(from, A):
|
||||
// Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart
|
||||
// and Judge are intended for the real participant; TurnEnd handled above.
|
||||
if (!IsRealForwardableFromScripted(from, env)) goto default;
|
||||
result.Add((other, env, false));
|
||||
break;
|
||||
|
||||
default:
|
||||
_log.LogDebug("BattleSessionV2 {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;
|
||||
}
|
||||
|
||||
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 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>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user