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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user