From f21ab7a38c7d35da120cfb73b2b5c6f3a8d6b988 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 3 Jun 2026 20:15:48 -0400 Subject: [PATCH] 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 --- SVSim.BattleNode/Bridge/BattleNodeOptions.cs | 11 -- SVSim.BattleNode/Bridge/MatchingBridge.cs | 3 - .../Hosting/BattleNodeWebSocketHandler.cs | 12 -- .../Lifecycle/ScriptedLifecycle.cs | 4 +- SVSim.BattleNode/Sessions/BattleSession.cs | 16 ++- .../Sessions/IBattleParticipant.cs | 2 - .../Participants/ScriptedBotParticipant.cs | 82 ------------ .../ArenaTwoPickBattleController.cs | 9 +- .../Controllers/RankBattleController.cs | 5 +- .../Matching/IMatchingResolver.cs | 11 +- .../Matching/MatchingResolver.cs | 14 --- SVSim.EmulatedEntrypoint/Program.cs | 7 +- .../appsettings.Development.json | 1 - .../BattleNode/Bridge/MatchingBridgeTests.cs | 12 +- .../Lifecycle/ScriptedLifecycleTests.cs | 4 +- .../Lifecycle/TypedBodyWireShapeTests.cs | 2 +- .../BattleSessionTerminateCascadeTests.cs | 9 +- .../InMemoryBattleSessionStoreTests.cs | 11 +- .../ArenaTwoPickBattleControllerTests.cs | 68 ++-------- .../Controllers/DoMatchingContractTests.cs | 118 ------------------ .../Matching/MatchingResolverTests.cs | 43 +------ 21 files changed, 49 insertions(+), 395 deletions(-) delete mode 100644 SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs delete mode 100644 SVSim.UnitTests/Controllers/DoMatchingContractTests.cs diff --git a/SVSim.BattleNode/Bridge/BattleNodeOptions.cs b/SVSim.BattleNode/Bridge/BattleNodeOptions.cs index dab4901..6b190a1 100644 --- a/SVSim.BattleNode/Bridge/BattleNodeOptions.cs +++ b/SVSim.BattleNode/Bridge/BattleNodeOptions.cs @@ -16,17 +16,6 @@ public sealed class BattleNodeOptions /// public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60); - /// - /// 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 ?scripted=1 on - /// every request. Turn off to test real PvP with two clients. Default false. - /// 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. - /// - public bool SoloDefaultsToScripted { get; set; } = false; - /// /// When true, emits per-frame /// diagnostic logs at Information level: [sio-in] on every inbound msg/alive/hand diff --git a/SVSim.BattleNode/Bridge/MatchingBridge.cs b/SVSim.BattleNode/Bridge/MatchingBridge.cs index 565250d..ad74daf 100644 --- a/SVSim.BattleNode/Bridge/MatchingBridge.cs +++ b/SVSim.BattleNode/Bridge/MatchingBridge.cs @@ -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."); } diff --git a/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs b/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs index 944a7a6..dab1317 100644 --- a/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs +++ b/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs @@ -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(), _options.DiagnosticLogging); - var scriptedBot = new ScriptedBotParticipant(); - var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot, - _loggerFactory.CreateLogger()); - await session.RunAsync(ctx.RequestAborted); - break; - } - case BattleType.Pvp: { // Pick this connection's MatchContext (P1's if isP1, P2's if isP2). diff --git a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs index 13fd832..0dfaa9c 100644 --- a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs +++ b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs @@ -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). diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 6a04b60..e219374 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -13,9 +13,8 @@ namespace SVSim.BattleNode.Sessions; /// flag) and dispatches via . /// /// -/// Phase 1 wires this for 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). /// 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 */ } } diff --git a/SVSim.BattleNode/Sessions/IBattleParticipant.cs b/SVSim.BattleNode/Sessions/IBattleParticipant.cs index a129f56..23152e1 100644 --- a/SVSim.BattleNode/Sessions/IBattleParticipant.cs +++ b/SVSim.BattleNode/Sessions/IBattleParticipant.cs @@ -9,8 +9,6 @@ namespace SVSim.BattleNode.Sessions; /// /// RealParticipant — WS-backed. /// NoOpBotParticipant — silent; for BattleType.Bot (AI-passive). -/// ScriptedBotParticipant — wraps the v1.2 lifecycle for -/// BattleType.Scripted (solo testing harness). /// /// public interface IBattleParticipant : IAsyncDisposable diff --git a/SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs b/SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs deleted file mode 100644 index bf5cfb1..0000000 --- a/SVSim.BattleNode/Sessions/Participants/ScriptedBotParticipant.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Linq; -using SVSim.BattleNode.Bridge; -using SVSim.BattleNode.Lifecycle; -using SVSim.BattleNode.Protocol; - -namespace SVSim.BattleNode.Sessions.Participants; - -/// -/// Server-scripted opponent that drives a client-shaped emit chain so the session brokers -/// it through the same handshake arms as a human. kicks off -/// InitNetwork; the session's pushes then drive reactively: -/// InitNetwork(ack)→InitBattle, MatchedLoaded, DealSwap -/// (empty mulligan). After the player's TurnEnd it fires the v1.2 three-frame burst -/// (OpponentTurnStart, OpponentTurnEnd, OpponentJudge). All other URIs -/// are swallowed. Implementing is what makes the session -/// treat it as a real handshake participant (mulligan-barrier swapper included). -/// -/// -/// ViewerId, Context are fixtures matching -/// 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) — -/// reads other.Context for those frames. -/// Deal still uses fixed scripted frames that ignore Context. -/// -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? 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; -} diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs index 7483f00..d43af87 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs @@ -27,20 +27,13 @@ public class ArenaTwoPickBattleController : SVSimController [HttpPost("do_matching")] public async Task DoMatching( [FromBody] DoMatchingRequest req, - [FromQuery(Name = "scripted")] string? scripted = null, CancellationToken ct = default) { if (!TryGetViewerId(out var vid)) return Unauthorized(); - // Accept "1" or "true" (case-insensitive) as per-request opt-in for the Scripted - // path. ASP.NET's default bool binder rejects "1", so parse permissively here. - // BattleNodeOptions.SoloDefaultsToScripted is the process-wide equivalent and is - // applied inside the resolver. - var scriptedOptIn = scripted is not null - && (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase)); try { var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid); - var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), scriptedOptIn, ct); + var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), ct); return Ok(new DoMatchingResponseDto { MatchingState = r.MatchingState, diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs index 0d525bd..11b036f 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs @@ -132,10 +132,7 @@ public sealed class RankBattleController : ControllerBase return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" }); } - // Rank battle has no ?scripted=1 query opt-in (no live capture has shown such a - // param on the rank URLs). The process-wide BattleNodeOptions.SoloDefaultsToScripted - // toggle is the only scripted entry point and is honored inside the resolver. - var r = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), scriptedOptIn: false, ct); + var r = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), ct); return Ok(new DoMatchingResponseDto { diff --git a/SVSim.EmulatedEntrypoint/Matching/IMatchingResolver.cs b/SVSim.EmulatedEntrypoint/Matching/IMatchingResolver.cs index 3a30e41..bcebb56 100644 --- a/SVSim.EmulatedEntrypoint/Matching/IMatchingResolver.cs +++ b/SVSim.EmulatedEntrypoint/Matching/IMatchingResolver.cs @@ -10,10 +10,7 @@ namespace SVSim.EmulatedEntrypoint.Matching; /// regardless of which URL family carried the request: /// /// -/// Honor the dev-affordance scripted opt-in (route flag and/or -/// ) — bypass pair-up, -/// register a Scripted match, return immediately. -/// Otherwise consult and translate the +/// Consult and translate the /// resulting into a wire matching_state per the /// 3002 / 3004 / 3007 / 3011 vocabulary. /// @@ -33,15 +30,9 @@ public interface IMatchingResolver /// "rotation_rank_battle", "unlimited_rank_battle"). /// /// Caller's (viewer-id + built MatchContext). - /// - /// Per-request opt-in from a controller-specific signal (e.g. TK2's ?scripted=1 - /// query param). OR'd with ; - /// either being true short-circuits to a Scripted match. - /// Task ResolveAsync( string mode, BattlePlayer player, - bool scriptedOptIn, CancellationToken ct); } diff --git a/SVSim.EmulatedEntrypoint/Matching/MatchingResolver.cs b/SVSim.EmulatedEntrypoint/Matching/MatchingResolver.cs index 2b2bd6d..ea5188c 100644 --- a/SVSim.EmulatedEntrypoint/Matching/MatchingResolver.cs +++ b/SVSim.EmulatedEntrypoint/Matching/MatchingResolver.cs @@ -1,5 +1,4 @@ using SVSim.BattleNode.Bridge; -using SVSim.BattleNode.Sessions; namespace SVSim.EmulatedEntrypoint.Matching; @@ -8,7 +7,6 @@ public sealed class MatchingResolver : IMatchingResolver { private readonly IMatchingBridge _bridge; private readonly IMatchingPairUpService _pairUp; - private readonly BattleNodeOptions _options; public MatchingResolver( IMatchingBridge bridge, @@ -17,25 +15,13 @@ public sealed class MatchingResolver : IMatchingResolver { _bridge = bridge; _pairUp = pairUp; - _options = options; } public Task ResolveAsync( string mode, BattlePlayer player, - bool scriptedOptIn, CancellationToken ct) { - // Dev-affordance short-circuit. Either a per-request flag (e.g. ?scripted=1) or the - // process-wide BattleNodeOptions.SoloDefaultsToScripted toggle puts us here. - // Registers a Scripted match (server-side scripted opponent in BattleSession) and - // returns matching_state=3004 SUCCEEDED so the client opens the WS and proceeds. - if (scriptedOptIn || _options.SoloDefaultsToScripted) - { - var m = _bridge.RegisterBattle(player, p2: null, BattleType.Scripted); - return Task.FromResult(new MatchingResolution(3004, m.BattleId, m.NodeServerUrl)); - } - return ResolveViaPairUpAsync(mode, player, ct); } diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 39b1ac6..31ff1b6 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -125,7 +125,7 @@ public class Program // BestHTTP's SocketManager parses this as the Socket.IO v2 endpoint URL. opt.NodeServerUrl = "localhost:5148/socket.io/"; // Any field in BattleNodeOptions can be overridden via the "BattleNode" section - // in appsettings*.json — see appsettings.Development.json for SoloDefaultsToScripted. + // in appsettings*.json — see appsettings.Development.json for DiagnosticLogging. builder.Configuration.GetSection("BattleNode").Bind(opt); }); // In-process FCFS pair-up for TK2 PvP /do_matching, plus rank-battle's AI-fallback @@ -138,9 +138,8 @@ public class Program new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback), })); builder.Services.AddSingleton(); - // Single resolver shared by every /do_matching family controller. Owns the scripted- - // flag short-circuit + the pair-up → matching_state mapping. Singleton: stateless, - // all deps are singletons too. + // Single resolver shared by every /do_matching family controller. Owns the + // pair-up → matching_state mapping. Singleton: stateless, all deps are singletons too. builder.Services.AddSingleton(); // Phase 3: bot roster used by RankBattleController.AiStart to compose oppo_info. // Transient because BotRoster depends on the transient IGlobalsRepository. diff --git a/SVSim.EmulatedEntrypoint/appsettings.Development.json b/SVSim.EmulatedEntrypoint/appsettings.Development.json index baed575..e9ba05f 100644 --- a/SVSim.EmulatedEntrypoint/appsettings.Development.json +++ b/SVSim.EmulatedEntrypoint/appsettings.Development.json @@ -9,7 +9,6 @@ "BypassSteamTicket": true }, "BattleNode": { - "SoloDefaultsToScripted": false, "DiagnosticLogging": true } } diff --git a/SVSim.UnitTests/BattleNode/Bridge/MatchingBridgeTests.cs b/SVSim.UnitTests/BattleNode/Bridge/MatchingBridgeTests.cs index 25ddec2..a0f030d 100644 --- a/SVSim.UnitTests/BattleNode/Bridge/MatchingBridgeTests.cs +++ b/SVSim.UnitTests/BattleNode/Bridge/MatchingBridgeTests.cs @@ -8,19 +8,19 @@ namespace SVSim.UnitTests.BattleNode.Bridge; public class MatchingBridgeTests { [Test] - public void RegisterBattle_Scripted_stores_pending_and_returns_node_url() + public void RegisterBattle_Bot_stores_pending_and_returns_node_url() { var store = new InMemoryBattleSessionStore(); var bridge = new MatchingBridge(store, new BattleNodeOptions { NodeServerUrl = "localhost:5148/socket.io/" }); var p1 = new BattlePlayer(906243102, FixtureCtx()); - var match = bridge.RegisterBattle(p1, p2: null, BattleType.Scripted); + var match = bridge.RegisterBattle(p1, p2: null, BattleType.Bot); Assert.That(match.NodeServerUrl, Is.EqualTo("localhost:5148/socket.io/")); Assert.That(match.BattleId, Is.Not.Empty); var pending = store.TryGetPending(match.BattleId); Assert.That(pending, Is.Not.Null); - Assert.That(pending!.Type, Is.EqualTo(BattleType.Scripted)); + Assert.That(pending!.Type, Is.EqualTo(BattleType.Bot)); Assert.That(pending.P1.ViewerId, Is.EqualTo(906243102)); Assert.That(pending.P2, Is.Null); } @@ -30,8 +30,8 @@ public class MatchingBridgeTests { var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions()); - var a = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Scripted); - var b = bridge.RegisterBattle(new BattlePlayer(2, FixtureCtx()), null, BattleType.Scripted); + var a = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Bot); + var b = bridge.RegisterBattle(new BattlePlayer(2, FixtureCtx()), null, BattleType.Bot); Assert.That(a.BattleId, Is.Not.EqualTo(b.BattleId)); } @@ -41,7 +41,7 @@ public class MatchingBridgeTests { var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions()); - var match = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Scripted); + var match = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Bot); Assert.That(match.BattleId, Has.Length.EqualTo(12)); Assert.That(match.BattleId, Does.Match("^[0-9]{12}$")); diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs index f2623be..b3a73cc 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs @@ -173,8 +173,8 @@ public class ScriptedLifecycleTests EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, BattleType: 11); - // Mirrors ScriptedBotParticipant.Context — the scripted opponent's MatchContext fixture - // that the new BuildMatched/BuildBattleStart helpers read from for the oppo half. + // A prod-captured opponent MatchContext fixture that the BuildMatched/BuildBattleStart + // helpers read from for the oppo half. private static MatchContext ScriptedBotCtx() => new( SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(), ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015", diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs index c796d3a..a37cfff 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs @@ -159,7 +159,7 @@ public class TypedBodyWireShapeTests EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, BattleType: 11); - // Mirrors ScriptedBotParticipant.Context — 30-card deck and the prod-captured opponent + // Prod-captured opponent fixture — 30-card deck and the prod-captured opponent // cosmetics (ClassId/CharaId "8") so the wire bytes asserted below (oppoInfo classId/charaId, // oppoDeckCount=30, etc.) remain byte-identical after the BuildMatched/BuildBattleStart // signature change. diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionTerminateCascadeTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionTerminateCascadeTests.cs index 46f493c..490d1a7 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionTerminateCascadeTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionTerminateCascadeTests.cs @@ -12,9 +12,8 @@ namespace SVSim.UnitTests.BattleNode.Sessions; /// /// Audit Md11 — confirms drops the per-RealParticipant /// archive when the session -/// terminates. The Scripted bot has no outbound archive of its own, so the test uses a -/// Scripted session (one Real, one ScriptedBot) and asserts only the Real side's archive -/// is cleared. +/// terminates. The NoOp bot has no outbound archive of its own, so the test uses a Bot +/// session (one Real, one NoOpBot) and asserts only the Real side's archive is cleared. /// [TestFixture] public class BattleSessionTerminateCascadeTests @@ -25,7 +24,7 @@ public class BattleSessionTerminateCascadeTests var ws = new TestWebSocket(); var real = new RealParticipant( ws, viewerId: 1, MakeFakeContext(), NullLogger.Instance); - var bot = new ScriptedBotParticipant(); + var bot = new NoOpBotParticipant(); // Pre-load the archive so we can prove it was cleared (not just empty). real.Outbound.AssignAndArchive(MakeEnvelope(NetworkBattleUri.Matched)); @@ -33,7 +32,7 @@ public class BattleSessionTerminateCascadeTests Assume.That(real.Outbound.Archive.Count, Is.EqualTo(2), "Precondition: archive populated."); var session = new BattleSession( - battleId: "test-bid", type: BattleType.Scripted, + battleId: "test-bid", type: BattleType.Bot, a: real, b: bot, log: NullLogger.Instance); // Drive RunAsync to completion: closing the incoming side causes diff --git a/SVSim.UnitTests/BattleNode/Sessions/InMemoryBattleSessionStoreTests.cs b/SVSim.UnitTests/BattleNode/Sessions/InMemoryBattleSessionStoreTests.cs index 4980678..bbb0cbc 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/InMemoryBattleSessionStoreTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/InMemoryBattleSessionStoreTests.cs @@ -1,4 +1,4 @@ -using NUnit.Framework; +using NUnit.Framework; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Sessions; @@ -14,7 +14,7 @@ public class InMemoryBattleSessionStoreTests [Test] public void RegisterThenGet_ReturnsRegisteredBattle() { - var battle = new PendingBattle("bid-1", BattleType.Scripted, new BattlePlayer(906243102, FixtureCtx()), null); + var battle = new PendingBattle("bid-1", BattleType.Bot, new BattlePlayer(906243102, FixtureCtx()), null); _store.RegisterPending(battle); Assert.That(_store.TryGetPending("bid-1"), Is.EqualTo(battle)); @@ -29,7 +29,7 @@ public class InMemoryBattleSessionStoreTests [Test] public void Remove_ReturnsTrueWhenPresent_FalseWhenAbsent() { - _store.RegisterPending(new PendingBattle("bid", BattleType.Scripted, new BattlePlayer(1, FixtureCtx()), null)); + _store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null)); Assert.That(_store.RemovePending("bid"), Is.True); Assert.That(_store.RemovePending("bid"), Is.False); } @@ -37,8 +37,8 @@ public class InMemoryBattleSessionStoreTests [Test] public void Register_DuplicateBattleId_OverwritesPrior() { - _store.RegisterPending(new PendingBattle("bid", BattleType.Scripted, new BattlePlayer(1, FixtureCtx()), null)); - _store.RegisterPending(new PendingBattle("bid", BattleType.Scripted, new BattlePlayer(2, FixtureCtx()), null)); + _store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null)); + _store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(2, FixtureCtx()), null)); Assert.That(_store.TryGetPending("bid")!.P1.ViewerId, Is.EqualTo(2)); } @@ -49,3 +49,4 @@ public class InMemoryBattleSessionStoreTests EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, BattleType: 11); } + diff --git a/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs b/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs index a2967c4..1cfdfa2 100644 --- a/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs +++ b/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs @@ -12,18 +12,24 @@ namespace SVSim.UnitTests.Controllers; public class ArenaTwoPickBattleControllerTests { [Test] - public async Task DoMatching_AuthenticatedViewer_Returns3004WithBattleIdAndNodeUrl() + public async Task DoMatching_joiner_Returns3004WithBattleIdAndNodeUrlAndCardMaster() { using var factory = new SVSimTestFactory(); - var viewerId = await factory.SeedViewerAsync(); - await SeedCompleteTwoPickRunAsync(factory, viewerId); + var vidA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_021UL); + var vidB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_022UL); + await SeedCompleteTwoPickRunAsync(factory, vidA); + await SeedCompleteTwoPickRunAsync(factory, vidB); + using var clientA = factory.CreateAuthenticatedClient(vidA); + using var clientB = factory.CreateAuthenticatedClient(vidB); - using var client = factory.CreateAuthenticatedClient(viewerId); var req = new { deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0, viewer_id = "0", steam_id = 0, steam_session_ticket = "", }; - var resp = await client.PostAsync("/arena_two_pick_battle/do_matching?scripted=1", JsonContent.Create(req)); + + // A parks first; B triggers the pair and gets the 3004 joiner response. + await clientA.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req)); + var resp = await clientB.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req)); Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); var body = await resp.Content.ReadAsStringAsync(); @@ -72,29 +78,6 @@ public class ArenaTwoPickBattleControllerTests Assert.That(root.GetProperty("node_server_url").GetString(), Is.EqualTo("")); } - [Test] - public async Task DoMatching_with_scripted_flag_returns_3004_Scripted_match_immediately() - { - using var factory = new SVSimTestFactory(); - var vid = await factory.SeedViewerAsync(); - await SeedCompleteTwoPickRunAsync(factory, vid); - using var client = factory.CreateAuthenticatedClient(vid); - - var req = new { - deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0, - viewer_id = "0", steam_id = 0, steam_session_ticket = "", - }; - var resp = await client.PostAsync("/arena_two_pick_battle/do_matching?scripted=1", JsonContent.Create(req)); - - Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - var body = await resp.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(body); - var root = doc.RootElement; - - Assert.That(root.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004)); - Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty); - } - [Test] public async Task DoMatching_two_pollers_get_3004_joiner_and_3007_owner_with_same_BattleId() { @@ -137,35 +120,6 @@ public class ArenaTwoPickBattleControllerTests "Owner and joiner must see the same node_server_url."); } - [Test] - public async Task DoMatching_SoloDefaultsToScripted_flag_makes_solo_poll_return_3004_without_query_param() - { - using var factory = new SVSimTestFactory(); - // BattleNodeOptions is a singleton in DI; flipping it before the request takes - // effect immediately for this factory. Real deployments toggle it via the - // "BattleNode:SoloDefaultsToScripted" key in appsettings*.json. - factory.Services.GetRequiredService().SoloDefaultsToScripted = true; - - var vid = await factory.SeedViewerAsync(); - await SeedCompleteTwoPickRunAsync(factory, vid); - using var client = factory.CreateAuthenticatedClient(vid); - - var req = new { - deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0, - viewer_id = "0", steam_id = 0, steam_session_ticket = "", - }; - // No ?scripted=1 — the flag alone should drive the Scripted branch. - var resp = await client.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req)); - - Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); - var root = doc.RootElement; - - Assert.That(root.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004), - "SoloDefaultsToScripted=true should bypass pair-up and return a Scripted 3004 SUCCEEDED."); - Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty); - Assert.That(root.GetProperty("node_server_url").GetString(), Does.Contain("/socket.io/")); - } [Test] public async Task DoMatching_NoActiveRun_Returns400WithErrorCode() diff --git a/SVSim.UnitTests/Controllers/DoMatchingContractTests.cs b/SVSim.UnitTests/Controllers/DoMatchingContractTests.cs deleted file mode 100644 index a5a5e33..0000000 --- a/SVSim.UnitTests/Controllers/DoMatchingContractTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; -using SVSim.BattleNode.Bridge; -using SVSim.Database; -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.UnitTests.Infrastructure; - -namespace SVSim.UnitTests.Controllers; - -/// -/// Cross-family contract for /do_matching. The single load-bearing assertion: when -/// is true, every family's -/// first poll must bypass pair-up and return a SUCCEEDED matching_state with a battle_id + -/// node_server_url — not the 3002 RETRY of the normal pair-up path. -/// -/// Adding a new family is the failure trigger for this test: the new controller MUST route -/// through , or this test -/// fails. That's the point — the test enforces "stay in line" across families. -/// -/// -[TestFixture] -public class DoMatchingContractTests -{ - private static readonly object DoMatchingBody = new - { - deck_no = 1L, - need_init = 1, - log = 1, - excluded_field_id_list = Array.Empty(), - use_stage_select = 1, - is_default_skin = 0, - viewer_id = "0", - steam_id = 0, - steam_session_ticket = "", - }; - - [TestCase("/arena_two_pick_battle/do_matching", FamilyKind.TwoPick)] - [TestCase("/rotation_rank_battle/do_matching", FamilyKind.RankRotation)] - [TestCase("/unlimited_rank_battle/do_matching", FamilyKind.RankUnlimited)] - public async Task SoloDefaultsToScripted_short_circuits_every_family_to_immediate_SUCCEEDED(string url, FamilyKind family) - { - await using var factory = new SVSimTestFactory(); - factory.Services.GetRequiredService().SoloDefaultsToScripted = true; - - var viewerId = await factory.SeedViewerAsync(); - await SetupFamilyAsync(factory, viewerId, family); - using var client = factory.CreateAuthenticatedClient(viewerId); - - var resp = await client.PostAsJsonAsync(url, DoMatchingBody); - - Assert.That(resp.IsSuccessStatusCode, Is.True, $"Expected 2xx from {url}, got {resp.StatusCode}."); - using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); - var root = doc.RootElement; - - var state = root.GetProperty("matching_state").GetInt32(); - Assert.That(state, Is.Not.EqualTo(3002), - $"{url}: SoloDefaultsToScripted=true must bypass pair-up; saw matching_state=3002 RETRY which means the family didn't honor the flag (probably forgot to route through IMatchingResolver)."); - Assert.That(state, Is.AnyOf(3004, 3007, 3011), - $"{url}: matching_state must be SUCCEEDED (3004), SUCCEEDED_OWNER (3007), or AI_SUCCEEDED (3011) — got {state}."); - - Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty, - $"{url}: SUCCEEDED responses must carry battle_id."); - Assert.That(root.GetProperty("node_server_url").GetString(), Does.Contain("/socket.io/"), - $"{url}: node_server_url must point at the WS endpoint."); - } - - // Each family has different prerequisites — TK2 needs an active draft run, rank needs - // a deck for the requested format. The factory's seeders are sufficient for both. - public enum FamilyKind { TwoPick, RankRotation, RankUnlimited } - - private static async Task SetupFamilyAsync(SVSimTestFactory factory, long viewerId, FamilyKind family) - { - switch (family) - { - case FamilyKind.TwoPick: - await SeedCompleteTwoPickRunAsync(factory, viewerId); - break; - case FamilyKind.RankRotation: - await factory.SeedGlobalsAsync(); - await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1); - break; - case FamilyKind.RankUnlimited: - await factory.SeedGlobalsAsync(); - await factory.SeedDeckAsync(viewerId, Format.Unlimited, number: 1); - break; - default: - throw new ArgumentOutOfRangeException(nameof(family)); - } - } - - // Mirrors ArenaTwoPickBattleControllerTests.SeedCompleteTwoPickRunAsync. Duplicated - // rather than promoted because the original is a private static there and only this - // test class needs to share it cross-family today; promote if a third caller surfaces. - private static async Task SeedCompleteTwoPickRunAsync(SVSimTestFactory factory, long viewerId) - { - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var deck = Enumerable.Range(1, 30).Select(i => 100_011_000L + i).ToList(); - db.ViewerArenaTwoPickRuns.Add(new ViewerArenaTwoPickRun - { - ViewerId = viewerId, - EntryId = 1, - ClassId = 1, - LeaderSkinId = 1, - SelectedCardIdsJson = JsonSerializer.Serialize(deck), - IsSelectCompleted = true, - MaxBattleCount = 5, - CandidateClassIdsJson = "[1,2,3]", - PendingPickSetsJson = "[]", - ResultListJson = "[]", - NextCandidateId = 1, - }); - await db.SaveChangesAsync(); - } -} diff --git a/SVSim.UnitTests/Matching/MatchingResolverTests.cs b/SVSim.UnitTests/Matching/MatchingResolverTests.cs index 51b4198..c93e9be 100644 --- a/SVSim.UnitTests/Matching/MatchingResolverTests.cs +++ b/SVSim.UnitTests/Matching/MatchingResolverTests.cs @@ -35,41 +35,6 @@ public class MatchingResolverTests CountryCode: "JP", UserName: $"P{vid}", SleeveId: "0", EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11)); - [Test] - public async Task When_scriptedOptIn_is_true_registers_Scripted_and_returns_3004() - { - var h = BuildHarness(); - var player = Player(); - h.Bridge.Setup(b => b.RegisterBattle(player, null, BattleType.Scripted)) - .Returns(new PendingMatch("bid-scripted", "node.local/socket.io/")); - - var r = await h.Resolver.ResolveAsync("arena_two_pick_battle", player, scriptedOptIn: true, default); - - Assert.That(r.MatchingState, Is.EqualTo(3004)); - Assert.That(r.BattleId, Is.EqualTo("bid-scripted")); - Assert.That(r.NodeServerUrl, Is.EqualTo("node.local/socket.io/")); - h.Bridge.VerifyAll(); - h.PairUp.Verify(p => p.TryPairAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [Test] - public async Task When_options_SoloDefaultsToScripted_is_true_registers_Scripted_for_any_mode() - { - // Cross-family contract: the process-wide flag overrides pair-up for every mode, - // not just TK2. - var h = BuildHarness(); - h.Options.SoloDefaultsToScripted = true; - var player = Player(); - h.Bridge.Setup(b => b.RegisterBattle(player, null, BattleType.Scripted)) - .Returns(new PendingMatch("bid-rank-scripted", "node.local/socket.io/")); - - var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, scriptedOptIn: false, default); - - Assert.That(r.MatchingState, Is.EqualTo(3004)); - Assert.That(r.BattleId, Is.EqualTo("bid-rank-scripted")); - h.PairUp.Verify(p => p.TryPairAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - [Test] public async Task When_neither_flag_set_calls_pairUp_and_parks_returns_3002_with_empty_url() { @@ -78,7 +43,7 @@ public class MatchingResolverTests h.PairUp.Setup(p => p.TryPairAsync("arena_two_pick_battle", player, It.IsAny())) .ReturnsAsync((PairUpResult?)null); - var r = await h.Resolver.ResolveAsync("arena_two_pick_battle", player, scriptedOptIn: false, default); + var r = await h.Resolver.ResolveAsync("arena_two_pick_battle", player, default); Assert.That(r.MatchingState, Is.EqualTo(3002)); Assert.That(r.BattleId, Is.Null); @@ -94,7 +59,7 @@ public class MatchingResolverTests h.PairUp.Setup(p => p.TryPairAsync("rotation_rank_battle", player, It.IsAny())) .ReturnsAsync(new PairUpResult(new PendingMatch("bid-x", "node.local/socket.io/"), IsOwner: true, IsAiFallback: false)); - var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, scriptedOptIn: false, default); + var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, default); Assert.That(r.MatchingState, Is.EqualTo(3007)); Assert.That(r.BattleId, Is.EqualTo("bid-x")); @@ -108,7 +73,7 @@ public class MatchingResolverTests h.PairUp.Setup(p => p.TryPairAsync("rotation_rank_battle", player, It.IsAny())) .ReturnsAsync(new PairUpResult(new PendingMatch("bid-x", "node.local/socket.io/"), IsOwner: false, IsAiFallback: false)); - var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, scriptedOptIn: false, default); + var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, default); Assert.That(r.MatchingState, Is.EqualTo(3004)); } @@ -122,7 +87,7 @@ public class MatchingResolverTests h.PairUp.Setup(p => p.TryPairAsync("unlimited_rank_battle", player, It.IsAny())) .ReturnsAsync(new PairUpResult(new PendingMatch("bid-ai", "node.local/socket.io/"), IsOwner: true, IsAiFallback: true)); - var r = await h.Resolver.ResolveAsync("unlimited_rank_battle", player, scriptedOptIn: false, default); + var r = await h.Resolver.ResolveAsync("unlimited_rank_battle", player, default); Assert.That(r.MatchingState, Is.EqualTo(3011)); }