refactor(battle-node): remove ScriptedBotParticipant and dev-affordance wiring

Deletes the scripted opponent and every entry point that created a
BattleType.Scripted session (the ?scripted=1 query opt-in, the
SoloDefaultsToScripted toggle, the resolver short-circuit, the WS handler case,
the bridge validation arm). Real two-client PvP and the Bot matchmaking-timeout
fallback are untouched. ResolveAsync drops its scriptedOptIn parameter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-03 20:15:48 -04:00
parent 8085119439
commit f21ab7a38c
21 changed files with 49 additions and 395 deletions

View File

@@ -16,17 +16,6 @@ public sealed class BattleNodeOptions
/// </summary>
public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Dev convenience: when true, matchmaking endpoints that would otherwise park
/// a solo poller (returning 3002 RETRY until a partner arrives) instead return
/// a Scripted match immediately — equivalent to passing <c>?scripted=1</c> on
/// every request. Turn off to test real PvP with two clients. Default false.
/// <para>Trade-off: while on, two viewers polling simultaneously each get
/// their own Scripted match instead of pairing with each other. Toggling off
/// is the only way to get PvP behavior.</para>
/// </summary>
public bool SoloDefaultsToScripted { get; set; } = false;
/// <summary>
/// When true, <see cref="Sessions.Participants.RealParticipant"/> emits per-frame
/// diagnostic logs at Information level: <c>[sio-in]</c> on every inbound msg/alive/hand

View File

@@ -47,9 +47,6 @@ public sealed class MatchingBridge : IMatchingBridge
case BattleType.Bot:
if (p2 is not null) throw new ArgumentException("Bot must have p2==null.", nameof(p2));
break;
case BattleType.Scripted:
// p2 currently null; future server-driven bot will populate it.
break;
default:
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown BattleType.");
}

View File

@@ -120,18 +120,6 @@ public sealed class BattleNodeWebSocketHandler
switch (pending.Type)
{
case BattleType.Scripted:
{
_store.RemovePending(battleId);
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
var scriptedBot = new ScriptedBotParticipant();
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
_loggerFactory.CreateLogger<BattleSession>());
await session.RunAsync(ctx.RequestAborted);
break;
}
case BattleType.Pvp:
{
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).

View File

@@ -127,8 +127,8 @@ public static class ScriptedLifecycle
IdxChangeSeed: ScriptedProfiles.ReadyIdxChangeSeed,
Spin: ScriptedProfiles.ReadySpin));
// --- Client-shaped emissions used by ScriptedBotParticipant so the session brokers
// the bot through the same handshake arms as a human. Bodies for the parameterless
// --- Client-shaped emissions (legacy scripted-bot scaffolding, pending removal) so the
// session brokers the bot through the same handshake arms as a human. Bodies for the parameterless
// handshake frames are ignored by the session (it reads from.Context / phase); only
// Swap's idxList is consumed (empty = keep the dealt hand).

View File

@@ -13,9 +13,8 @@ namespace SVSim.BattleNode.Sessions;
/// 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).
/// Wires both battle modes: Pvp (broadcast Matched/BattleStart per-perspective, forward
/// gameplay frames between the two real participants) and Bot (ack-only, NoOp opponent).
/// </remarks>
public sealed class BattleSession
{
@@ -85,10 +84,9 @@ public sealed class BattleSession
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.
// WhenAny: first WS drop / first graceful close triggers cascade. Pvp has two
// RealParticipants; we synthesize a BattleFinish for the survivor if either side
// terminates first.
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
var survivor = first == aTask ? B : A;
@@ -118,8 +116,8 @@ public sealed class BattleSession
}
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.
// Bot mode: the NoOp opponent's RunAsync returns immediately; wait for the real
// participant. The session keeps running for the real one.
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
catch { /* swallow */ }
}

View File

@@ -9,8 +9,6 @@ namespace SVSim.BattleNode.Sessions;
/// <list type="bullet">
/// <item><c>RealParticipant</c> — WS-backed.</item>
/// <item><c>NoOpBotParticipant</c> — silent; for <c>BattleType.Bot</c> (AI-passive).</item>
/// <item><c>ScriptedBotParticipant</c> — wraps the v1.2 lifecycle for
/// <c>BattleType.Scripted</c> (solo testing harness).</item>
/// </list>
/// </summary>
public interface IBattleParticipant : IAsyncDisposable

View File

@@ -1,82 +0,0 @@
using System.Linq;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
namespace SVSim.BattleNode.Sessions.Participants;
/// <summary>
/// Server-scripted opponent that drives a client-shaped emit chain so the session brokers
/// it through the same handshake arms as a human. <see cref="RunAsync"/> kicks off
/// <c>InitNetwork</c>; the session's pushes then drive <see cref="PushAsync"/> reactively:
/// <c>InitNetwork</c>(ack)→<c>InitBattle</c>, <c>Matched</c>→<c>Loaded</c>, <c>Deal</c>→<c>Swap</c>
/// (empty mulligan). After the player's <c>TurnEnd</c> it fires the v1.2 three-frame burst
/// (<c>OpponentTurnStart</c>, <c>OpponentTurnEnd</c>, <c>OpponentJudge</c>). All other URIs
/// are swallowed. Implementing <see cref="IHasHandshakePhase"/> is what makes the session
/// treat it as a real handshake participant (mulligan-barrier swapper included).
/// </summary>
/// <remarks>
/// ViewerId, Context are fixtures matching <see cref="ScriptedLifecycle.FakeOpponentViewerId"/>
/// and a scripted opponent profile. The Context fixture is the source of truth for the
/// scripted opponent's half of Matched and BattleStart (cosmetics, deck count, class/chara) —
/// <see cref="BattleSession.ComputeFrames"/> reads <c>other.Context</c> for those frames.
/// Deal still uses fixed scripted frames that ignore Context.
/// </remarks>
public sealed class ScriptedBotParticipant : IBattleParticipant, IHasHandshakePhase
{
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
public MatchContext Context { get; } = new(
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 — prod frame[2] (Matched)
// shipped OppoDeckCount: 30.
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
// BattleStart opponent half (frame[5]): ClassId/CharaId both "8" (neutral test class).
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
// Matched opponent half (frame[2]): cosmetic fields from the prod capture.
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
BattleType: 0);
// Session reads/advances this through its phase-gated handshake arms, exactly as it
// does for a RealParticipant. The bot doesn't read it — it reacts to pushed URIs —
// but implementing IHasHandshakePhase is what makes the session treat the bot as a
// real handshake participant (so its InitNetwork/InitBattle/Loaded/Swap emissions are
// processed, and the mulligan barrier counts it as a swapper).
public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
// Kick off the handshake like a connecting client. The session acks InitNetwork,
// which drives PushAsync below through InitBattle → Loaded → Swap.
public Task RunAsync(CancellationToken ct) => EmitAsync(ScriptedLifecycle.BuildClientInitNetwork(), ct);
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
{
switch (envelope.Uri)
{
case NetworkBattleUri.InitNetwork: // the ack
await EmitAsync(ScriptedLifecycle.BuildClientInitBattle(), ct).ConfigureAwait(false);
break;
case NetworkBattleUri.Matched:
await EmitAsync(ScriptedLifecycle.BuildClientLoaded(), ct).ConfigureAwait(false);
break;
case NetworkBattleUri.Deal:
await EmitAsync(ScriptedLifecycle.BuildClientSwap(), ct).ConfigureAwait(false);
break;
case NetworkBattleUri.TurnEnd:
// v1.2 scripted-turn burst, taken AFTER the player's turn (bot is second).
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false);
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);
await EmitAsync(ScriptedLifecycle.BuildOpponentJudge(), ct).ConfigureAwait(false);
break;
// Everything else (BattleStart, our own Swap-response, Ready, TurnEndFinal,
// Judge, BattleFinish, …) needs no bot reaction.
}
}
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private Task EmitAsync(MsgEnvelope env, CancellationToken ct) =>
FrameEmitted?.Invoke(env, ct) ?? Task.CompletedTask;
}