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:
gamer147
2026-06-05 07:21:59 -04:00
parent 7d4da69f22
commit 3e8901eec3
17 changed files with 82 additions and 65 deletions

View File

@@ -39,7 +39,7 @@ internal sealed class BattleSessionState
return deck;
}
public BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
public SessionLifecycle Lifecycle { get; set; } = SessionLifecycle.Active;
public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new();
/// <summary>Per-side idx->cardId, seeded lazily from <see cref="MatchContext.SelfDeckCardIds"/>.

View File

@@ -20,7 +20,7 @@ internal sealed class FrameDispatchContext
/// <summary>The dispatching participant's handshake phase (null for a non-IHasHandshakePhase
/// participant, e.g. NoOpBot). Setting it advances the sender.</summary>
internal BattleSessionPhase? SenderPhase
internal HandshakePhase? SenderPhase
{
get => (From as IHasHandshakePhase)?.Phase;
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
/// result is identical regardless of which side sent the frame — matches legacy BothAfterReady.</summary>
internal bool BothAfterReady() =>
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady &&
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady;
(A as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady &&
(B as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady;
}

View File

@@ -8,18 +8,18 @@ internal sealed class InitBattleHandler : IFrameHandler
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{
// 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>
{
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), Stock.Bypass),
};
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
ctx.SenderPhase = HandshakePhase.AwaitingLoaded;
return r;
}
// 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>
{
@@ -28,7 +28,7 @@ internal sealed class InitBattleHandler : IFrameHandler
ctx.BattleId, BattleSeeds.Stable(ctx.State.MasterSeed),
ctx.State.GetShuffledDeck(ctx.From)), Stock.Normal),
};
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
ctx.SenderPhase = HandshakePhase.AwaitingLoaded;
return r;
}

View File

@@ -7,14 +7,14 @@ internal sealed class InitNetworkHandler : IFrameHandler
{
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{
if (ctx.SenderPhase != BattleSessionPhase.AwaitingInitNetwork)
if (ctx.SenderPhase != HandshakePhase.AwaitingInitNetwork)
return Array.Empty<DispatchRoute>();
var routes = new List<DispatchRoute>
{
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), Stock.Bypass),
};
ctx.SenderPhase = BattleSessionPhase.AwaitingInitBattle;
ctx.SenderPhase = HandshakePhase.AwaitingInitBattle;
return routes;
}
}

View File

@@ -8,14 +8,14 @@ internal sealed class LoadedHandler : IFrameHandler
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{
// 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>();
}
// 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.
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),
new(ctx.From, ServerBattleFrames.BuildDeal(), Stock.Normal),
};
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap;
ctx.SenderPhase = HandshakePhase.AwaitingSwap;
return r;
}

View File

@@ -6,7 +6,7 @@ internal sealed class RetireKillHandler : IFrameHandler
{
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
// 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.

View File

@@ -8,7 +8,7 @@ internal sealed class SwapHandler : IFrameHandler
{
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{
if (ctx.SenderPhase != BattleSessionPhase.AwaitingSwap)
if (ctx.SenderPhase != HandshakePhase.AwaitingSwap)
return Array.Empty<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.
routes.Add(new DispatchRoute(ctx.From, ServerBattleFrames.BuildSwapResponse(hand), Stock.Normal));
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.
// IHasHandshakePhase membership IS the "participates in mulligan" set.

View File

@@ -7,13 +7,13 @@ internal sealed class TurnEndFinalHandler : IFrameHandler
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{
// 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) };
// 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
// OPPOSITE of RetireKillHandler (From LOSES there — retire is self-inflicted).
// Intentional — do NOT "consistency-fix" the two handlers to match; a swap here

View File

@@ -8,12 +8,12 @@ internal sealed class TurnEndHandler : IFrameHandler
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{
// 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) };
// 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.
if (ctx.SenderPhase == BattleSessionPhase.AfterReady)
if (ctx.SenderPhase == HandshakePhase.AfterReady)
{
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
{