diff --git a/SVSim.BattleNode/Bridge/BattleNodeOptions.cs b/SVSim.BattleNode/Bridge/BattleNodeOptions.cs
index dbf448f..40306ca 100644
--- a/SVSim.BattleNode/Bridge/BattleNodeOptions.cs
+++ b/SVSim.BattleNode/Bridge/BattleNodeOptions.cs
@@ -15,4 +15,15 @@ public sealed class BattleNodeOptions
/// in tests via the factory.
///
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;
}
diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs
index 8bf8357..3dab706 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs
@@ -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);
diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs
index ffb8078..12492fd 100644
--- a/SVSim.EmulatedEntrypoint/Program.cs
+++ b/SVSim.EmulatedEntrypoint/Program.cs
@@ -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
diff --git a/SVSim.EmulatedEntrypoint/appsettings.Development.json b/SVSim.EmulatedEntrypoint/appsettings.Development.json
index 0c208ae..b61e819 100644
--- a/SVSim.EmulatedEntrypoint/appsettings.Development.json
+++ b/SVSim.EmulatedEntrypoint/appsettings.Development.json
@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
+ },
+ "BattleNode": {
+ "SoloDefaultsToScripted": false
}
}
diff --git a/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs b/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs
index 461ba9f..a2967c4 100644
--- a/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs
+++ b/SVSim.UnitTests/Controllers/ArenaTwoPickBattleControllerTests.cs
@@ -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().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()
{