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 Microsoft.AspNetCore.Mvc;
|
||||||
using SVSim.BattleNode.Bridge;
|
using SVSim.BattleNode.Bridge;
|
||||||
|
using SVSim.EmulatedEntrypoint.Matching;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
|
||||||
using SVSim.EmulatedEntrypoint.Services;
|
using SVSim.EmulatedEntrypoint.Services;
|
||||||
@@ -12,33 +13,67 @@ public class ArenaTwoPickBattleController : SVSimController
|
|||||||
private readonly IArenaTwoPickService _svc;
|
private readonly IArenaTwoPickService _svc;
|
||||||
private readonly IMatchingBridge _matching;
|
private readonly IMatchingBridge _matching;
|
||||||
private readonly IMatchContextBuilder _matchContextBuilder;
|
private readonly IMatchContextBuilder _matchContextBuilder;
|
||||||
|
private readonly IMatchingPairUpService _pairUp;
|
||||||
|
|
||||||
public ArenaTwoPickBattleController(
|
public ArenaTwoPickBattleController(
|
||||||
IArenaTwoPickService svc,
|
IArenaTwoPickService svc,
|
||||||
IMatchingBridge matching,
|
IMatchingBridge matching,
|
||||||
IMatchContextBuilder matchContextBuilder)
|
IMatchContextBuilder matchContextBuilder,
|
||||||
|
IMatchingPairUpService pairUp)
|
||||||
{
|
{
|
||||||
_svc = svc;
|
_svc = svc;
|
||||||
_matching = matching;
|
_matching = matching;
|
||||||
_matchContextBuilder = matchContextBuilder;
|
_matchContextBuilder = matchContextBuilder;
|
||||||
|
_pairUp = pairUp;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("do_matching")]
|
[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();
|
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
|
try
|
||||||
{
|
{
|
||||||
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
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),
|
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
|
||||||
p2: null,
|
ct);
|
||||||
SVSim.BattleNode.Sessions.BattleType.Scripted);
|
if (paired is null)
|
||||||
|
{
|
||||||
|
return Ok(new DoMatchingResponseDto
|
||||||
|
{
|
||||||
|
MatchingState = 3001,
|
||||||
|
// BattleId / NodeServerUrl null — client polls again.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new DoMatchingResponseDto
|
return Ok(new DoMatchingResponseDto
|
||||||
{
|
{
|
||||||
MatchingState = 3004,
|
MatchingState = 3004,
|
||||||
BattleId = match.BattleId,
|
BattleId = paired.BattleId,
|
||||||
NodeServerUrl = match.NodeServerUrl,
|
NodeServerUrl = paired.NodeServerUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (ArenaTwoPickException ex)
|
catch (ArenaTwoPickException ex)
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ public sealed class DoMatchingResponseDto
|
|||||||
public int RetryPeriod { get; set; } = 3;
|
public int RetryPeriod { get; set; } = 3;
|
||||||
|
|
||||||
[JsonPropertyName("battle_id")] [Key("battle_id")]
|
[JsonPropertyName("battle_id")] [Key("battle_id")]
|
||||||
public string BattleId { get; set; } = "";
|
public string? BattleId { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("node_server_url")] [Key("node_server_url")]
|
[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} —
|
// Required by the client when matching_state ∈ {3004, 3007, 3011} —
|
||||||
// DoMatchingBase.SettingCardMasterId does jsonData["card_master_id"].ToInt()
|
// 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,
|
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 = "",
|
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));
|
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
@@ -39,6 +39,93 @@ public class ArenaTwoPickBattleControllerTests
|
|||||||
Assert.That(root.GetProperty("card_master_id").GetInt32(), Is.EqualTo(1));
|
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]
|
[Test]
|
||||||
public async Task DoMatching_NoActiveRun_Returns400WithErrorCode()
|
public async Task DoMatching_NoActiveRun_Returns400WithErrorCode()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user