feat(battle-node): RealParticipant session-finished signal + Pvp cascade
RealParticipant gains _sessionFinished TCS + MarkSessionFinished / AwaitSessionFinishedAsync. PvP first-arriver's handler awaits the signal instead of calling self.RunAsync (which the session does internally on the same instance — double-call would race the WS read). BattleSession.RunAsync branches on Type: Pvp uses WhenAny + synthesize BattleFinish(Win) to survivor + WhenAll(drain); Scripted/Bot keep Phase 1's WhenAll-everything semantics. Disconnect cascade now drives end-of-battle when a WS drops without a graceful Retire.
This commit is contained in:
@@ -43,18 +43,53 @@ public sealed class BattleSession
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
// Run both participants' inbound loops in parallel and wait for them all to
|
||||
// complete. NoOp/Scripted bots return immediately; Real returns when the WS
|
||||
// closes. Using WhenAny here would have killed the session as soon as the
|
||||
// scripted bot's no-op RunAsync resolved. Phase 2's Pvp/Bot cases will need
|
||||
// disconnect propagation; that's wired in their own task.
|
||||
var aTask = A.RunAsync(cancellation);
|
||||
var bTask = B.RunAsync(cancellation);
|
||||
try { await Task.WhenAll(aTask, bTask); } catch { /* swallow 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 */ }
|
||||
}
|
||||
|
||||
await Task.WhenAll(
|
||||
A.TerminateAsync(BattleFinishReason.NormalFinish),
|
||||
B.TerminateAsync(BattleFinishReason.NormalFinish));
|
||||
B.TerminateAsync(BattleFinishReason.NormalFinish))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
|
||||
|
||||
Reference in New Issue
Block a user