refactor(battlenode): split BattleSessionPhase into HandshakePhase + SessionLifecycle
Behavior-preserving; 231 BattleNode tests green. One enum conflated two axes. Split: - HandshakePhase (per participant): AwaitingInitNetwork..AfterReady. On IHasHandshakePhase.Phase, FrameDispatchContext.SenderPhase, the handler gates. - SessionLifecycle (per battle): Active | Terminal. On the renamed BattleSessionState.Lifecycle (was SessionPhase, defaulting to a handshake value) and BattleSession.Lifecycle (was Phase). Reads are only != Terminal, so the Active default is behavior-identical. OpponentTurn was dead (never assigned) -> dropped. BattleSessionPhase deleted; the two axes can no longer be cross-assigned. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ public sealed class BattleSession
|
|||||||
public BattleType Type { get; }
|
public BattleType Type { get; }
|
||||||
public IBattleParticipant A { get; }
|
public IBattleParticipant A { get; }
|
||||||
public IBattleParticipant B { get; }
|
public IBattleParticipant B { get; }
|
||||||
public BattleSessionPhase Phase => _state.SessionPhase;
|
public SessionLifecycle Lifecycle => _state.Lifecycle;
|
||||||
|
|
||||||
// Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown
|
// Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown
|
||||||
// URIs are dropped with a LogDebug in ComputeFrames.
|
// URIs are dropped with a LogDebug in ComputeFrames.
|
||||||
@@ -104,7 +104,7 @@ public sealed class BattleSession
|
|||||||
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
|
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
|
||||||
var survivor = first == aTask ? B : A;
|
var survivor = first == aTask ? B : A;
|
||||||
|
|
||||||
if (Phase != BattleSessionPhase.Terminal)
|
if (Lifecycle != SessionLifecycle.Terminal)
|
||||||
{
|
{
|
||||||
// Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin)
|
// Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin)
|
||||||
// to survivor. DisconnectWin=201 → client renders "opponent disconnected" →
|
// to survivor. DisconnectWin=201 → client renders "opponent disconnected" →
|
||||||
@@ -121,7 +121,7 @@ public sealed class BattleSession
|
|||||||
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
|
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
|
||||||
BattleId);
|
BattleId);
|
||||||
}
|
}
|
||||||
_state.SessionPhase = BattleSessionPhase.Terminal;
|
_state.Lifecycle = SessionLifecycle.Terminal;
|
||||||
}
|
}
|
||||||
|
|
||||||
cts.Cancel(); // unblock the survivor's RunAsync read loop
|
cts.Cancel(); // unblock the survivor's RunAsync read loop
|
||||||
@@ -202,8 +202,8 @@ public sealed class BattleSession
|
|||||||
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));
|
||||||
|
|
||||||
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
|
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in lifecycle={Lifecycle} from vid={Vid}",
|
||||||
BattleId, env.Uri, Phase, from.ViewerId);
|
BattleId, env.Uri, Lifecycle, from.ViewerId);
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace SVSim.BattleNode.Sessions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Where we are in the v1 server-authored frame lifecycle. Drives which server-authored frames
|
|
||||||
/// the session pushes in response to inbound emits.
|
|
||||||
/// </summary>
|
|
||||||
public enum BattleSessionPhase
|
|
||||||
{
|
|
||||||
AwaitingInitNetwork,
|
|
||||||
AwaitingInitBattle,
|
|
||||||
AwaitingLoaded,
|
|
||||||
AwaitingSwap,
|
|
||||||
AfterReady,
|
|
||||||
OpponentTurn,
|
|
||||||
Terminal,
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,7 @@ internal sealed class BattleSessionState
|
|||||||
return deck;
|
return deck;
|
||||||
}
|
}
|
||||||
|
|
||||||
public BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
public SessionLifecycle Lifecycle { get; set; } = SessionLifecycle.Active;
|
||||||
public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new();
|
public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new();
|
||||||
|
|
||||||
/// <summary>Per-side idx->cardId, seeded lazily from <see cref="MatchContext.SelfDeckCardIds"/>.
|
/// <summary>Per-side idx->cardId, seeded lazily from <see cref="MatchContext.SelfDeckCardIds"/>.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ internal sealed class FrameDispatchContext
|
|||||||
|
|
||||||
/// <summary>The dispatching participant's handshake phase (null for a non-IHasHandshakePhase
|
/// <summary>The dispatching participant's handshake phase (null for a non-IHasHandshakePhase
|
||||||
/// participant, e.g. NoOpBot). Setting it advances the sender.</summary>
|
/// participant, e.g. NoOpBot). Setting it advances the sender.</summary>
|
||||||
internal BattleSessionPhase? SenderPhase
|
internal HandshakePhase? SenderPhase
|
||||||
{
|
{
|
||||||
get => (From as IHasHandshakePhase)?.Phase;
|
get => (From as IHasHandshakePhase)?.Phase;
|
||||||
set { if (From is IHasHandshakePhase p && value is { } v) p.Phase = v; }
|
set { if (From is IHasHandshakePhase p && value is { } v) p.Phase = v; }
|
||||||
@@ -29,6 +29,6 @@ internal sealed class FrameDispatchContext
|
|||||||
/// <summary>Both participants have completed the handshake. Reads A/B (not From/Other) so the
|
/// <summary>Both participants have completed the handshake. Reads A/B (not From/Other) so the
|
||||||
/// result is identical regardless of which side sent the frame — matches legacy BothAfterReady.</summary>
|
/// result is identical regardless of which side sent the frame — matches legacy BothAfterReady.</summary>
|
||||||
internal bool BothAfterReady() =>
|
internal bool BothAfterReady() =>
|
||||||
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady &&
|
(A as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady &&
|
||||||
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady;
|
(B as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,18 @@ internal sealed class InitBattleHandler : IFrameHandler
|
|||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
// case 2: Bot — ack only, NO Matched (Matched would corrupt client opponent info).
|
// case 2: Bot — ack only, NO Matched (Matched would corrupt client opponent info).
|
||||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AwaitingInitBattle)
|
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == HandshakePhase.AwaitingInitBattle)
|
||||||
{
|
{
|
||||||
var r = new List<DispatchRoute>
|
var r = new List<DispatchRoute>
|
||||||
{
|
{
|
||||||
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), Stock.Bypass),
|
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), Stock.Bypass),
|
||||||
};
|
};
|
||||||
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
|
ctx.SenderPhase = HandshakePhase.AwaitingLoaded;
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// case 5: general — push Matched (per-perspective) to the sender only.
|
// case 5: general — push Matched (per-perspective) to the sender only.
|
||||||
if (ctx.SenderPhase == BattleSessionPhase.AwaitingInitBattle)
|
if (ctx.SenderPhase == HandshakePhase.AwaitingInitBattle)
|
||||||
{
|
{
|
||||||
var r = new List<DispatchRoute>
|
var r = new List<DispatchRoute>
|
||||||
{
|
{
|
||||||
@@ -28,7 +28,7 @@ internal sealed class InitBattleHandler : IFrameHandler
|
|||||||
ctx.BattleId, BattleSeeds.Stable(ctx.State.MasterSeed),
|
ctx.BattleId, BattleSeeds.Stable(ctx.State.MasterSeed),
|
||||||
ctx.State.GetShuffledDeck(ctx.From)), Stock.Normal),
|
ctx.State.GetShuffledDeck(ctx.From)), Stock.Normal),
|
||||||
};
|
};
|
||||||
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
|
ctx.SenderPhase = HandshakePhase.AwaitingLoaded;
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ internal sealed class InitNetworkHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
if (ctx.SenderPhase != BattleSessionPhase.AwaitingInitNetwork)
|
if (ctx.SenderPhase != HandshakePhase.AwaitingInitNetwork)
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
|
|
||||||
var routes = new List<DispatchRoute>
|
var routes = new List<DispatchRoute>
|
||||||
{
|
{
|
||||||
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), Stock.Bypass),
|
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), Stock.Bypass),
|
||||||
};
|
};
|
||||||
ctx.SenderPhase = BattleSessionPhase.AwaitingInitBattle;
|
ctx.SenderPhase = HandshakePhase.AwaitingInitBattle;
|
||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ internal sealed class LoadedHandler : IFrameHandler
|
|||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
// case 3: Bot — silent (client populates opponent state from AIBattleStart HTTP data).
|
// case 3: Bot — silent (client populates opponent state from AIBattleStart HTTP data).
|
||||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AwaitingLoaded)
|
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == HandshakePhase.AwaitingLoaded)
|
||||||
{
|
{
|
||||||
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap;
|
ctx.SenderPhase = HandshakePhase.AwaitingSwap;
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// case 6: general — BattleStart (per-perspective) + Deal to the sender.
|
// case 6: general — BattleStart (per-perspective) + Deal to the sender.
|
||||||
if (ctx.SenderPhase == BattleSessionPhase.AwaitingLoaded)
|
if (ctx.SenderPhase == HandshakePhase.AwaitingLoaded)
|
||||||
{
|
{
|
||||||
// A goes first deterministically; B goes second.
|
// A goes first deterministically; B goes second.
|
||||||
var turnState = ReferenceEquals(ctx.From, ctx.A) ? TurnState.First : TurnState.Second;
|
var turnState = ReferenceEquals(ctx.From, ctx.A) ? TurnState.First : TurnState.Second;
|
||||||
@@ -25,7 +25,7 @@ internal sealed class LoadedHandler : IFrameHandler
|
|||||||
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, turnState), Stock.Normal),
|
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, turnState), Stock.Normal),
|
||||||
new(ctx.From, ServerBattleFrames.BuildDeal(), Stock.Normal),
|
new(ctx.From, ServerBattleFrames.BuildDeal(), Stock.Normal),
|
||||||
};
|
};
|
||||||
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap;
|
ctx.SenderPhase = HandshakePhase.AwaitingSwap;
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ internal sealed class RetireKillHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
ctx.State.SessionPhase = BattleSessionPhase.Terminal;
|
ctx.State.Lifecycle = SessionLifecycle.Terminal;
|
||||||
// Polarity: the SENDER retired, so From LOSES / Other WINS. This is the OPPOSITE of
|
// Polarity: the SENDER retired, so From LOSES / Other WINS. This is the OPPOSITE of
|
||||||
// TurnEndFinalHandler (From WINS there — sender dealt the lethal). Intentional — do NOT
|
// TurnEndFinalHandler (From WINS there — sender dealt the lethal). Intentional — do NOT
|
||||||
// "consistency-fix" the two handlers to match; a swap here silently reverses every retire.
|
// "consistency-fix" the two handlers to match; a swap here silently reverses every retire.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ internal sealed class SwapHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
if (ctx.SenderPhase != BattleSessionPhase.AwaitingSwap)
|
if (ctx.SenderPhase != HandshakePhase.AwaitingSwap)
|
||||||
return Array.Empty<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
|
|
||||||
var routes = new List<DispatchRoute>();
|
var routes = new List<DispatchRoute>();
|
||||||
@@ -17,7 +17,7 @@ internal sealed class SwapHandler : IFrameHandler
|
|||||||
// SwapResponse is always immediate — completes the sender's own mulligan UI.
|
// SwapResponse is always immediate — completes the sender's own mulligan UI.
|
||||||
routes.Add(new DispatchRoute(ctx.From, ServerBattleFrames.BuildSwapResponse(hand), Stock.Normal));
|
routes.Add(new DispatchRoute(ctx.From, ServerBattleFrames.BuildSwapResponse(hand), Stock.Normal));
|
||||||
ctx.State.PostSwapHands[ctx.From] = hand;
|
ctx.State.PostSwapHands[ctx.From] = hand;
|
||||||
ctx.SenderPhase = BattleSessionPhase.AfterReady;
|
ctx.SenderPhase = HandshakePhase.AfterReady;
|
||||||
|
|
||||||
// Release Ready to every swapper once all handshake-driving participants have swapped.
|
// Release Ready to every swapper once all handshake-driving participants have swapped.
|
||||||
// IHasHandshakePhase membership IS the "participates in mulligan" set.
|
// IHasHandshakePhase membership IS the "participates in mulligan" set.
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ internal sealed class TurnEndFinalHandler : IFrameHandler
|
|||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
// case 4: Bot — Judge to sender only.
|
// case 4: Bot — Judge to sender only.
|
||||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == HandshakePhase.AfterReady)
|
||||||
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), Stock.Normal) };
|
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), Stock.Normal) };
|
||||||
|
|
||||||
// case 9: general — forward the envelope to other + paired BattleFinish + Terminal.
|
// case 9: general — forward the envelope to other + paired BattleFinish + Terminal.
|
||||||
if (ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
if (ctx.SenderPhase == HandshakePhase.AfterReady)
|
||||||
{
|
{
|
||||||
ctx.State.SessionPhase = BattleSessionPhase.Terminal;
|
ctx.State.Lifecycle = SessionLifecycle.Terminal;
|
||||||
// Polarity: the SENDER dealt the lethal, so From WINS / Other LOSES. This is the
|
// Polarity: the SENDER dealt the lethal, so From WINS / Other LOSES. This is the
|
||||||
// OPPOSITE of RetireKillHandler (From LOSES there — retire is self-inflicted).
|
// OPPOSITE of RetireKillHandler (From LOSES there — retire is self-inflicted).
|
||||||
// Intentional — do NOT "consistency-fix" the two handlers to match; a swap here
|
// Intentional — do NOT "consistency-fix" the two handlers to match; a swap here
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ internal sealed class TurnEndHandler : IFrameHandler
|
|||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
{
|
{
|
||||||
// case 4: Bot — Judge to sender only (no real opponent; client flips back to its local AI).
|
// case 4: Bot — Judge to sender only (no real opponent; client flips back to its local AI).
|
||||||
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == HandshakePhase.AfterReady)
|
||||||
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), Stock.Normal) };
|
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), Stock.Normal) };
|
||||||
|
|
||||||
// case 8: general AfterReady arm — PvP forwards a {turnState} TurnEnd to the opponent
|
// case 8: general AfterReady arm — PvP forwards a {turnState} TurnEnd to the opponent
|
||||||
// (handover gate). Any non-Pvp non-Bot type that reaches AfterReady consumes the frame.
|
// (handover gate). Any non-Pvp non-Bot type that reaches AfterReady consumes the frame.
|
||||||
if (ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
if (ctx.SenderPhase == HandshakePhase.AfterReady)
|
||||||
{
|
{
|
||||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||||
{
|
{
|
||||||
|
|||||||
18
SVSim.BattleNode/Sessions/HandshakePhase.cs
Normal file
18
SVSim.BattleNode/Sessions/HandshakePhase.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-participant progression through the v1 server-authored setup handshake. Each side advances
|
||||||
|
/// InitNetwork → InitBattle → Loaded → Swap → AfterReady as the session acks its emits. Tracked
|
||||||
|
/// per participant via <see cref="Participants.IHasHandshakePhase"/>; the session reads the
|
||||||
|
/// SENDER's phase (<see cref="Dispatch.FrameDispatchContext.SenderPhase"/>) to gate which setup
|
||||||
|
/// frame to author next. Distinct from the session-global <see cref="SessionLifecycle"/> — this is
|
||||||
|
/// one axis per side, that is one axis per battle.
|
||||||
|
/// </summary>
|
||||||
|
public enum HandshakePhase
|
||||||
|
{
|
||||||
|
AwaitingInitNetwork,
|
||||||
|
AwaitingInitBattle,
|
||||||
|
AwaitingLoaded,
|
||||||
|
AwaitingSwap,
|
||||||
|
AfterReady,
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ namespace SVSim.BattleNode.Sessions.Participants;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal interface IHasHandshakePhase
|
internal interface IHasHandshakePhase
|
||||||
{
|
{
|
||||||
BattleSessionPhase Phase { get; set; }
|
HandshakePhase Phase { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -66,9 +66,9 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
/// because they never send the gating URIs. Also satisfies
|
/// because they never send the gating URIs. Also satisfies
|
||||||
/// <see cref="IHasHandshakePhase"/> (the interface BattleSession uses to gate
|
/// <see cref="IHasHandshakePhase"/> (the interface BattleSession uses to gate
|
||||||
/// handshake dispatch without depending on the concrete RealParticipant type).</summary>
|
/// handshake dispatch without depending on the concrete RealParticipant type).</summary>
|
||||||
internal BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
internal HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork;
|
||||||
|
|
||||||
BattleSessionPhase IHasHandshakePhase.Phase
|
HandshakePhase IHasHandshakePhase.Phase
|
||||||
{
|
{
|
||||||
get => Phase;
|
get => Phase;
|
||||||
set => Phase = value;
|
set => Phase = value;
|
||||||
|
|||||||
15
SVSim.BattleNode/Sessions/SessionLifecycle.cs
Normal file
15
SVSim.BattleNode/Sessions/SessionLifecycle.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session-global lifecycle. A battle stays <see cref="Active"/> until a terminal event — a lethal
|
||||||
|
/// TurnEndFinal, a Retire/Kill, or the disconnect drop cascade — flips it to <see cref="Terminal"/>,
|
||||||
|
/// after which the drop cascade will not synthesize another BattleFinish. Distinct from the
|
||||||
|
/// per-participant <see cref="HandshakePhase"/> (which side reached which setup step); this is one
|
||||||
|
/// axis per battle. Only these two states are load-bearing — the handshake progression lives on the
|
||||||
|
/// other enum.
|
||||||
|
/// </summary>
|
||||||
|
public enum SessionLifecycle
|
||||||
|
{
|
||||||
|
Active,
|
||||||
|
Terminal,
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ public class BattleSessionDispatchConcurrencyTests
|
|||||||
private readonly ConcurrencyDetector _detector;
|
private readonly ConcurrencyDetector _detector;
|
||||||
public long ViewerId { get; }
|
public long ViewerId { get; }
|
||||||
public MatchContext Context { get; }
|
public MatchContext Context { get; }
|
||||||
public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
public HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork;
|
||||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||||
|
|
||||||
public ProbeParticipant(long viewerId, MatchContext context, ConcurrencyDetector detector)
|
public ProbeParticipant(long viewerId, MatchContext context, ConcurrencyDetector detector)
|
||||||
|
|||||||
@@ -44,19 +44,19 @@ public class BattleSessionDispatchTests
|
|||||||
var s = new BattleSession("bid-1", BattleType.Pvp, a, b, NullLogger<BattleSession>.Instance);
|
var s = new BattleSession("bid-1", BattleType.Pvp, a, b, NullLogger<BattleSession>.Instance);
|
||||||
|
|
||||||
// A is AwaitingInitNetwork; B is AwaitingInitBattle (manually set).
|
// A is AwaitingInitNetwork; B is AwaitingInitBattle (manually set).
|
||||||
b.Phase = BattleSessionPhase.AwaitingInitBattle;
|
b.Phase = HandshakePhase.AwaitingInitBattle;
|
||||||
|
|
||||||
// A's InitNetwork should ack (matches A's phase).
|
// A's InitNetwork should ack (matches A's phase).
|
||||||
var routesA = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
var routesA = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
Assert.That(routesA.Count, Is.EqualTo(1));
|
Assert.That(routesA.Count, Is.EqualTo(1));
|
||||||
Assert.That(routesA[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
Assert.That(routesA[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingInitBattle));
|
||||||
|
|
||||||
// B's InitBattle should produce Matched (matches B's phase, set above).
|
// B's InitBattle should produce Matched (matches B's phase, set above).
|
||||||
var routesB = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
|
var routesB = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
Assert.That(routesB.Count, Is.EqualTo(1));
|
Assert.That(routesB.Count, Is.EqualTo(1));
|
||||||
Assert.That(routesB[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
Assert.That(routesB[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||||
Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
Assert.That(b.Phase, Is.EqualTo(HandshakePhase.AwaitingLoaded));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -66,7 +66,7 @@ public class BattleSessionDispatchTests
|
|||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||||
|
|
||||||
Assert.That(routes, Is.Empty);
|
Assert.That(routes, Is.Empty);
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork));
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingInitNetwork));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -169,7 +169,7 @@ public class BattleSessionDispatchTests
|
|||||||
|
|
||||||
Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }),
|
Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }),
|
||||||
"Ready is withheld until BOTH sides have mulliganed.");
|
"Ready is withheld until BOTH sides have mulliganed.");
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady),
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AfterReady),
|
||||||
"Phase advances on Swap even though Ready is withheld.");
|
"Phase advances on Swap even though Ready is withheld.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,7 +833,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||||
Assert.That(((BattleFinishBody)routes[2].Frame.Body).Result, Is.EqualTo(BattleResult.LifeLose));
|
Assert.That(((BattleFinishBody)routes[2].Frame.Body).Result, Is.EqualTo(BattleResult.LifeLose));
|
||||||
|
|
||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
Assert.That(s.Lifecycle, Is.EqualTo(SessionLifecycle.Terminal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -866,7 +866,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
|
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
|
||||||
Assert.That(aRoute.Stock, Is.EqualTo(Stock.Bypass));
|
Assert.That(aRoute.Stock, Is.EqualTo(Stock.Bypass));
|
||||||
Assert.That(bRoute.Stock, Is.EqualTo(Stock.Bypass));
|
Assert.That(bRoute.Stock, Is.EqualTo(Stock.Bypass));
|
||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
Assert.That(s.Lifecycle, Is.EqualTo(SessionLifecycle.Terminal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -879,7 +879,7 @@ public class BattleSessionDispatchTests
|
|||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(2));
|
Assert.That(routes.Count, Is.EqualTo(2));
|
||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
Assert.That(s.Lifecycle, Is.EqualTo(SessionLifecycle.Terminal));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
|
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
|
||||||
@@ -905,7 +905,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
Assert.That(routes.Count, Is.EqualTo(1));
|
||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingInitBattle));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -923,7 +923,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitBattle),
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitBattle),
|
||||||
"Expected an ack envelope for InitBattle, NOT a Matched envelope.");
|
"Expected an ack envelope for InitBattle, NOT a Matched envelope.");
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingLoaded));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -939,7 +939,7 @@ public class BattleSessionDispatchTests
|
|||||||
// handler at Matching.cs:417 → SetNetworkInfo overwrites it with our
|
// handler at Matching.cs:417 → SetNetworkInfo overwrites it with our
|
||||||
// placeholder NoOpBotParticipant.Context zeros).
|
// placeholder NoOpBotParticipant.Context zeros).
|
||||||
Assert.That(routes, Is.Empty, "Bot Loaded is silent.");
|
Assert.That(routes, Is.Empty, "Bot Loaded is silent.");
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap),
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingSwap),
|
||||||
"Phase still advances even though there are no outbound routes.");
|
"Phase still advances even though there are no outbound routes.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,7 +956,7 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
Assert.That(routes.Select(r => r.Frame.Uri),
|
||||||
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
||||||
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AfterReady));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -1114,7 +1114,7 @@ public class BattleSessionDispatchTests
|
|||||||
{
|
{
|
||||||
public long ViewerId { get; }
|
public long ViewerId { get; }
|
||||||
public MatchContext Context { get; }
|
public MatchContext Context { get; }
|
||||||
public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
public HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork;
|
||||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||||
public FakeRealParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
|
public FakeRealParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
|
||||||
public Task PushAsync(MsgEnvelope env, Stock stock, CancellationToken ct) => Task.CompletedTask;
|
public Task PushAsync(MsgEnvelope env, Stock stock, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ public class RealParticipantTests
|
|||||||
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
|
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
|
||||||
NullLogger<RealParticipant>.Instance);
|
NullLogger<RealParticipant>.Instance);
|
||||||
|
|
||||||
Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AwaitingInitNetwork));
|
Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.HandshakePhase.AwaitingInitNetwork));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -108,9 +108,9 @@ public class RealParticipantTests
|
|||||||
NullLogger<RealParticipant>.Instance);
|
NullLogger<RealParticipant>.Instance);
|
||||||
|
|
||||||
// Setter is `internal`; SVSim.UnitTests has InternalsVisibleTo on SVSim.BattleNode.
|
// Setter is `internal`; SVSim.UnitTests has InternalsVisibleTo on SVSim.BattleNode.
|
||||||
p.Phase = SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady;
|
p.Phase = SVSim.BattleNode.Sessions.HandshakePhase.AfterReady;
|
||||||
|
|
||||||
Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady));
|
Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.HandshakePhase.AfterReady));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -121,9 +121,9 @@ public class RealParticipantTests
|
|||||||
var a = new RealParticipant(wsA, viewerId: 1, FixtureCtx(), NullLogger<RealParticipant>.Instance);
|
var a = new RealParticipant(wsA, viewerId: 1, FixtureCtx(), NullLogger<RealParticipant>.Instance);
|
||||||
var b = new RealParticipant(wsB, viewerId: 2, FixtureCtx(), NullLogger<RealParticipant>.Instance);
|
var b = new RealParticipant(wsB, viewerId: 2, FixtureCtx(), NullLogger<RealParticipant>.Instance);
|
||||||
|
|
||||||
a.Phase = SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady;
|
a.Phase = SVSim.BattleNode.Sessions.HandshakePhase.AfterReady;
|
||||||
|
|
||||||
Assert.That(b.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AwaitingInitNetwork),
|
Assert.That(b.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.HandshakePhase.AwaitingInitNetwork),
|
||||||
"B's Phase must not change when A's Phase is set.");
|
"B's Phase must not change when A's Phase is set.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user