feat(arena-tk2): SoloDefaultsToScripted config flag for dev convenience

Adds BattleNodeOptions.SoloDefaultsToScripted (default false). When true,
the TK2 do_matching controller treats every solo poll as if ?scripted=1
were passed and returns a Scripted 3004 match immediately — useful for
the live client (which can't append query params) to drive the scripted
bot without needing a second player.

Toggle via "BattleNode:SoloDefaultsToScripted" in appsettings*.json
(Program.cs now binds the BattleNode section over the AddBattleNode
defaults). Turn off to test real PvP with two clients.

Trade-off documented on the option: while on, two simultaneous pollers
each get their own Scripted match instead of pairing, so PvP is
effectively disabled until the flag is flipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-01 23:48:14 -04:00
parent 8112b3f81f
commit 0095bdf0cf
5 changed files with 58 additions and 3 deletions

View File

@@ -15,4 +15,15 @@ public sealed class BattleNodeOptions
/// in tests via the factory.
/// </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;
}

View File

@@ -14,17 +14,20 @@ public class ArenaTwoPickBattleController : SVSimController
private readonly IMatchingBridge _matching;
private readonly IMatchContextBuilder _matchContextBuilder;
private readonly IMatchingPairUpService _pairUp;
private readonly BattleNodeOptions _battleNodeOptions;
public ArenaTwoPickBattleController(
IArenaTwoPickService svc,
IMatchingBridge matching,
IMatchContextBuilder matchContextBuilder,
IMatchingPairUpService pairUp)
IMatchingPairUpService pairUp,
BattleNodeOptions battleNodeOptions)
{
_svc = svc;
_matching = matching;
_matchContextBuilder = matchContextBuilder;
_pairUp = pairUp;
_battleNodeOptions = battleNodeOptions;
}
[HttpPost("do_matching")]
@@ -36,8 +39,12 @@ public class ArenaTwoPickBattleController : SVSimController
if (!TryGetViewerId(out var vid)) return Unauthorized();
// Accept "1" or "true" (case-insensitive) as opt-in for the legacy Scripted path.
// ASP.NET's default bool binder rejects "1", so do a permissive parse here.
var useScripted = scripted is not null
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase));
// The server-side BattleNodeOptions.SoloDefaultsToScripted flag is the other
// route — it bypasses pair-up for every solo poll, useful when the live client
// (which can't append query params) needs a Scripted match.
var useScripted = (scripted is not null
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase)))
|| _battleNodeOptions.SoloDefaultsToScripted;
try
{
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);

View File

@@ -124,6 +124,9 @@ public class Program
// Matches the prod do_matching wire format: host:port/socket.io/, no scheme prefix.
// 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.
builder.Configuration.GetSection("BattleNode").Bind(opt);
});
// In-process FCFS pair-up for TK2 PvP /do_matching. Singleton: per-mode state is
// process-wide. Proper queue API is a separate spec; this is enough to actually

View File

@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"BattleNode": {
"SoloDefaultsToScripted": false
}
}

View File

@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using SVSim.BattleNode.Bridge;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
@@ -136,6 +137,36 @@ 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<BattleNodeOptions>().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()
{