refactor(battlenode): key dispatch on OpponentIsAckOnly, drop per-frame BattleType switch

Behavior-identical; 231 BattleNode tests green with ZERO test changes.

The 10 handler arms no longer switch on BattleType:
- 4 Bot arms gate on the new FrameDispatchContext.OpponentIsAckOnly
  (Other is not IHasHandshakePhase) — the participant property the audit asked for.
- 6 relay arms drop the Type == Pvp guard; it was redundant with BothSidesAfterReady()
  (only a two-real-player session has both handshake phases). Its doc now records that.
- FrameDispatchContext.Type removed (+ the Type = Type in BuildContext). BattleSession.Type
  stays for the session-level drop cascade.

Zero test churn because the stubs already encode the split: FakeRealParticipant/ProbeParticipant
implement IHasHandshakePhase, the bot stub FakeParticipant doesn't, and NewBotSession uses it as
the opponent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-05 08:20:56 -04:00
parent 9ff8948903
commit 2d32051cc0
11 changed files with 23 additions and 13 deletions

View File

@@ -71,7 +71,7 @@ public sealed class BattleSession
new() new()
{ {
A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A, A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A,
Env = env, Type = Type, BattleId = BattleId, State = _state, Env = env, BattleId = BattleId, State = _state,
}; };
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b, public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,

View File

@@ -14,10 +14,17 @@ internal sealed class FrameDispatchContext
internal required IBattleParticipant From { get; init; } internal required IBattleParticipant From { get; init; }
internal required IBattleParticipant Other { get; init; } internal required IBattleParticipant Other { get; init; }
internal required MsgEnvelope Env { get; init; } internal required MsgEnvelope Env { get; init; }
internal required BattleType Type { get; init; }
internal required string BattleId { get; init; } internal required string BattleId { get; init; }
internal required BattleSessionState State { get; init; } internal required BattleSessionState State { get; init; }
/// <summary>The opponent is an AI-passive (ack-only) bot: it runs no handshake — no
/// <see cref="IHasHandshakePhase"/> — and receives no relayed frames (the client drives its own
/// AI; the server only acks). This is the participant property that replaces the per-handler
/// <c>BattleType.Bot</c> switch: the Bot dispatch arms gate on it. Its inverse — a live relay
/// peer — is what <see cref="BothSidesAfterReady"/> already implies (only real peers have a
/// handshake phase), so the relay arms need no separate opponent check.</summary>
internal bool OpponentIsAckOnly => Other is not IHasHandshakePhase;
/// <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 HandshakePhase? SenderPhase internal HandshakePhase? SenderPhase
@@ -34,7 +41,10 @@ internal sealed class FrameDispatchContext
/// <summary>BOTH participants have finished the handshake. Reads A/B (not From/Other) so the /// <summary>BOTH participants have finished the handshake. Reads A/B (not From/Other) so the
/// result is identical regardless of which side sent the frame. Contrast /// result is identical regardless of which side sent the frame. Contrast
/// <see cref="SenderIsAfterReady"/> (sender only).</summary> /// <see cref="SenderIsAfterReady"/> (sender only). Only a live relay peer (real player) has a
/// handshake phase, so this can only be true in a two-real-player (PvP) session — the relay
/// dispatch arms gate on this instead of a <c>BattleType</c> check (an ack-only bot opponent,
/// <see cref="OpponentIsAckOnly"/>, can never satisfy it).</summary>
internal bool BothSidesAfterReady() => internal bool BothSidesAfterReady() =>
(A as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady && (A as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady &&
(B as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady; (B as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady;

View File

@@ -13,7 +13,7 @@ internal sealed class EchoHandler : IFrameHandler
{ {
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
if (ctx.Type == BattleType.Pvp && ctx.BothSidesAfterReady()) if (ctx.BothSidesAfterReady())
{ {
var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault(WireKeys.OrderList); var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault(WireKeys.OrderList);
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList); ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);

View File

@@ -8,7 +8,7 @@ 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 == HandshakePhase.AwaitingInitBattle) if (ctx.OpponentIsAckOnly && ctx.SenderPhase == HandshakePhase.AwaitingInitBattle)
{ {
var r = new List<DispatchRoute> var r = new List<DispatchRoute>
{ {

View File

@@ -15,7 +15,7 @@ internal sealed class JudgeHandler : IFrameHandler
// start another one, stalling the loop; confirmed by the 2026-06-03 two-client capture). // start another one, stalling the loop; confirmed by the 2026-06-03 two-client capture).
// The sender then emits TurnStart, which TurnStartHandler relays to the opponent as {spin}. // The sender then emits TurnStart, which TurnStartHandler relays to the opponent as {spin}.
// battleCode is dropped; spin=0 for the deterministic-turn slice. // battleCode is dropped; spin=0 for the deterministic-turn slice.
if (ctx.Type == BattleType.Pvp && ctx.BothSidesAfterReady()) if (ctx.BothSidesAfterReady())
{ {
var frame = ctx.Env with { Body = new JudgeBody(Spin: BattleFrameDefaults.DeterministicTurnSpin) }; var frame = ctx.Env with { Body = new JudgeBody(Spin: BattleFrameDefaults.DeterministicTurnSpin) };
return new[] { new DispatchRoute(ctx.From, frame, Stock.Normal) }; return new[] { new DispatchRoute(ctx.From, frame, Stock.Normal) };

View File

@@ -8,7 +8,7 @@ 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 == HandshakePhase.AwaitingLoaded) if (ctx.OpponentIsAckOnly && ctx.SenderPhase == HandshakePhase.AwaitingLoaded)
{ {
ctx.SenderPhase = HandshakePhase.AwaitingSwap; ctx.SenderPhase = HandshakePhase.AwaitingSwap;
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();

View File

@@ -14,7 +14,7 @@ internal sealed class PlayActionsHandler : IFrameHandler
{ {
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
if (ctx.Type != BattleType.Pvp || !ctx.BothSidesAfterReady()) if (!ctx.BothSidesAfterReady())
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();
var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary<string, object?>(); var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary<string, object?>();

View File

@@ -9,7 +9,7 @@ internal sealed class TurnEndActionsHandler : IFrameHandler
{ {
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
if (ctx.Type == BattleType.Pvp && ctx.BothSidesAfterReady()) if (ctx.BothSidesAfterReady())
{ {
var frame = ctx.Env with { Body = new RawBody(new Dictionary<string, object?>()) }; var frame = ctx.Env with { Body = new RawBody(new Dictionary<string, object?>()) };
return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) }; return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) };

View File

@@ -7,7 +7,7 @@ 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.SenderIsAfterReady) if (ctx.OpponentIsAckOnly && ctx.SenderIsAfterReady)
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.

View File

@@ -8,14 +8,14 @@ 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.SenderIsAfterReady) if (ctx.OpponentIsAckOnly && ctx.SenderIsAfterReady)
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.SenderIsAfterReady) if (ctx.SenderIsAfterReady)
{ {
if (ctx.Type == BattleType.Pvp && ctx.BothSidesAfterReady()) if (ctx.BothSidesAfterReady())
{ {
// Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate): // Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate):
// the opponent (the turn taker-over) then sends a Judge, which JudgeHandler reflects // the opponent (the turn taker-over) then sends a Judge, which JudgeHandler reflects

View File

@@ -10,7 +10,7 @@ internal sealed class TurnStartHandler : IFrameHandler
{ {
// PvP: the active player's TurnStart{orderList} is dropped; the opponent receives {spin} // PvP: the active player's TurnStart{orderList} is dropped; the opponent receives {spin}
// (spin=0 for the deterministic-turn slice) and self-generates its turn-open. // (spin=0 for the deterministic-turn slice) and self-generates its turn-open.
if (ctx.Type == BattleType.Pvp && ctx.BothSidesAfterReady()) if (ctx.BothSidesAfterReady())
{ {
var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: BattleFrameDefaults.DeterministicTurnSpin) }; var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: BattleFrameDefaults.DeterministicTurnSpin) };
return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) }; return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) };