feat(arena-tk2): PvP pair-up trigger via /do_matching, ?scripted=1 opt-in

Solo pollers park (3001 RETRY); two concurrent pollers pair and both
receive 3004 + same BattleId. Cache hits on the first arriver's next
poll. ?scripted=1 retains today's solo Scripted path for dev work.
Response DTO's BattleId/NodeServerUrl become nullable so 3001 omits
them on the wire (WhenWritingNull policy drops them).

ASP.NET's default bool binder rejects "1" as a value, so the scripted
opt-in is bound as string? and parsed permissively (accepts "1" and
"true"/"True"/etc.) rather than relying on built-in bool binding.
This commit is contained in:
gamer147
2026-06-01 22:14:04 -04:00
parent 28b1d7531a
commit 225c20daeb
3 changed files with 132 additions and 10 deletions

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.BattleNode.Bridge;
using SVSim.EmulatedEntrypoint.Matching;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Services;
@@ -12,33 +13,67 @@ public class ArenaTwoPickBattleController : SVSimController
private readonly IArenaTwoPickService _svc;
private readonly IMatchingBridge _matching;
private readonly IMatchContextBuilder _matchContextBuilder;
private readonly IMatchingPairUpService _pairUp;
public ArenaTwoPickBattleController(
IArenaTwoPickService svc,
IMatchingBridge matching,
IMatchContextBuilder matchContextBuilder)
IMatchContextBuilder matchContextBuilder,
IMatchingPairUpService pairUp)
{
_svc = svc;
_matching = matching;
_matchContextBuilder = matchContextBuilder;
_pairUp = pairUp;
}
[HttpPost("do_matching")]
public async Task<IActionResult> DoMatching([FromBody] DoMatchingRequest req)
public async Task<IActionResult> 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 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));
try
{
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
var match = _matching.RegisterBattle(
if (useScripted)
{
var scriptedMatch = _matching.RegisterBattle(
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
p2: null,
SVSim.BattleNode.Sessions.BattleType.Scripted);
return Ok(new DoMatchingResponseDto
{
MatchingState = 3004,
BattleId = scriptedMatch.BattleId,
NodeServerUrl = scriptedMatch.NodeServerUrl,
});
}
var paired = await _pairUp.TryPairAsync(
"arena_two_pick_battle",
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
p2: null,
SVSim.BattleNode.Sessions.BattleType.Scripted);
ct);
if (paired is null)
{
return Ok(new DoMatchingResponseDto
{
MatchingState = 3001,
// BattleId / NodeServerUrl null — client polls again.
});
}
return Ok(new DoMatchingResponseDto
{
MatchingState = 3004,
BattleId = match.BattleId,
NodeServerUrl = match.NodeServerUrl,
BattleId = paired.BattleId,
NodeServerUrl = paired.NodeServerUrl,
});
}
catch (ArenaTwoPickException ex)

View File

@@ -17,10 +17,10 @@ public sealed class DoMatchingResponseDto
public int RetryPeriod { get; set; } = 3;
[JsonPropertyName("battle_id")] [Key("battle_id")]
public string BattleId { get; set; } = "";
public string? BattleId { get; set; }
[JsonPropertyName("node_server_url")] [Key("node_server_url")]
public string NodeServerUrl { get; set; } = "";
public string? NodeServerUrl { get; set; }
// Required by the client when matching_state ∈ {3004, 3007, 3011} —
// DoMatchingBase.SettingCardMasterId does jsonData["card_master_id"].ToInt()

View File

@@ -22,7 +22,7 @@ public class ArenaTwoPickBattleControllerTests
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", JsonContent.Create(req));
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();
@@ -39,6 +39,93 @@ public class ArenaTwoPickBattleControllerTests
Assert.That(root.GetProperty("card_master_id").GetInt32(), Is.EqualTo(1));
}
[Test]
public async Task DoMatching_solo_poller_returns_3001_RETRY_with_no_BattleId()
{
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", 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(3001));
// BattleId should be ABSENT from the JSON (WhenWritingNull) — TryGetProperty
// returns false when the key isn't present.
Assert.That(root.TryGetProperty("battle_id", out _), Is.False,
"battle_id must be absent from the wire when matching_state==3001 RETRY.");
}
[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_concurrent_pollers_both_return_3004_with_same_BattleId()
{
using var factory = new SVSimTestFactory();
var vidA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_011UL);
var vidB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_012UL);
await SeedCompleteTwoPickRunAsync(factory, vidA);
await SeedCompleteTwoPickRunAsync(factory, vidB);
using var clientA = factory.CreateAuthenticatedClient(vidA);
using var clientB = factory.CreateAuthenticatedClient(vidB);
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 = "",
};
// A polls first (parks).
var respA1 = await clientA.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req));
using var docA1 = JsonDocument.Parse(await respA1.Content.ReadAsStringAsync());
Assert.That(docA1.RootElement.GetProperty("matching_state").GetInt32(), Is.EqualTo(3001),
"A's first poll parks.");
// B polls (pairs).
var respB = await clientB.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req));
using var docB = JsonDocument.Parse(await respB.Content.ReadAsStringAsync());
Assert.That(docB.RootElement.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004),
"B's poll pairs with A.");
var bBattleId = docB.RootElement.GetProperty("battle_id").GetString();
Assert.That(bBattleId, Is.Not.Null.And.Not.Empty);
// A polls again, picks up the cached result.
var respA2 = await clientA.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req));
using var docA2 = JsonDocument.Parse(await respA2.Content.ReadAsStringAsync());
Assert.That(docA2.RootElement.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004),
"A's second poll picks up the cached match.");
Assert.That(docA2.RootElement.GetProperty("battle_id").GetString(), Is.EqualTo(bBattleId));
}
[Test]
public async Task DoMatching_NoActiveRun_Returns400WithErrorCode()
{