feat(replay): wire arena two-pick finish hook
Same pattern as rank-battle: DoMatching stashes context; Finish takes it and records history + played-together. Opponent identity is left as placeholder fields until the resolver carries it through. Test seeds an active ViewerArenaTwoPickRun so RecordBattleResultAsync does not throw no_active_run during the e2e flow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SVSim.BattleNode.Bridge;
|
using SVSim.BattleNode.Bridge;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
using SVSim.Database.Services.Friend;
|
||||||
|
using SVSim.Database.Services.Replay;
|
||||||
|
using SVSim.EmulatedEntrypoint.Extensions;
|
||||||
using SVSim.EmulatedEntrypoint.Matching;
|
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;
|
||||||
@@ -13,15 +17,24 @@ public class ArenaTwoPickBattleController : SVSimController
|
|||||||
private readonly IArenaTwoPickService _svc;
|
private readonly IArenaTwoPickService _svc;
|
||||||
private readonly IMatchContextBuilder _matchContextBuilder;
|
private readonly IMatchContextBuilder _matchContextBuilder;
|
||||||
private readonly IMatchingResolver _resolver;
|
private readonly IMatchingResolver _resolver;
|
||||||
|
private readonly IBattleContextStore _battleContextStore;
|
||||||
|
private readonly IBattleHistoryWriter _historyWriter;
|
||||||
|
private readonly IPlayedTogetherWriter _playedTogetherWriter;
|
||||||
|
|
||||||
public ArenaTwoPickBattleController(
|
public ArenaTwoPickBattleController(
|
||||||
IArenaTwoPickService svc,
|
IArenaTwoPickService svc,
|
||||||
IMatchContextBuilder matchContextBuilder,
|
IMatchContextBuilder matchContextBuilder,
|
||||||
IMatchingResolver resolver)
|
IMatchingResolver resolver,
|
||||||
|
IBattleContextStore battleContextStore,
|
||||||
|
IBattleHistoryWriter historyWriter,
|
||||||
|
IPlayedTogetherWriter playedTogetherWriter)
|
||||||
{
|
{
|
||||||
_svc = svc;
|
_svc = svc;
|
||||||
_matchContextBuilder = matchContextBuilder;
|
_matchContextBuilder = matchContextBuilder;
|
||||||
_resolver = resolver;
|
_resolver = resolver;
|
||||||
|
_battleContextStore = battleContextStore;
|
||||||
|
_historyWriter = historyWriter;
|
||||||
|
_playedTogetherWriter = playedTogetherWriter;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("do_matching")]
|
[HttpPost("do_matching")]
|
||||||
@@ -34,6 +47,38 @@ public class ArenaTwoPickBattleController : SVSimController
|
|||||||
{
|
{
|
||||||
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
||||||
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), ct);
|
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), ct);
|
||||||
|
|
||||||
|
if (r.BattleId is not null && long.TryParse(r.BattleId, out var battleIdLong))
|
||||||
|
{
|
||||||
|
_battleContextStore.Set(vid, new BattleContext(
|
||||||
|
BattleId: battleIdLong,
|
||||||
|
// Two-pick wire battle_type — see docs/api-spec/common/types.ts.md
|
||||||
|
// #battle-types. Captured prod frames use 4 for both private match
|
||||||
|
// AND arena two-pick contexts; if a future capture disagrees, refine.
|
||||||
|
BattleType: 4,
|
||||||
|
DeckFormat: Format.TwoPick.ToApi(), // wire-int 10
|
||||||
|
TwoPickType: 0, // captured "0"; refine once tracked on MatchContext
|
||||||
|
SelfClassId: (int)ctx.ClassId, // CardClass enum
|
||||||
|
SelfSubClassId: 0,
|
||||||
|
SelfCharaId: int.TryParse(ctx.CharaId, out var ch) ? ch : 0,
|
||||||
|
SelfRotationId: "0",
|
||||||
|
// MatchContext (SVSim.BattleNode/Bridge/MatchContext.cs) does NOT carry
|
||||||
|
// opponent identity — the resolver returns only the BattleId. Leave
|
||||||
|
// opponent placeholders; when the two-pick matchmaking flow plumbs the
|
||||||
|
// second player's MatchContext through to the resolver result, fill
|
||||||
|
// these from there (and stash for both players).
|
||||||
|
OpponentViewerId: 0,
|
||||||
|
OpponentName: "",
|
||||||
|
OpponentClassId: 0,
|
||||||
|
OpponentSubClassId: 0,
|
||||||
|
OpponentCharaId: 0,
|
||||||
|
OpponentCountryCode: "",
|
||||||
|
OpponentEmblemId: 0,
|
||||||
|
OpponentDegreeId: 0,
|
||||||
|
OpponentRotationId: "0",
|
||||||
|
BattleStartTime: DateTime.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new DoMatchingResponseDto
|
return Ok(new DoMatchingResponseDto
|
||||||
{
|
{
|
||||||
MatchingState = r.MatchingState,
|
MatchingState = r.MatchingState,
|
||||||
@@ -48,12 +93,30 @@ public class ArenaTwoPickBattleController : SVSimController
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("finish")]
|
[HttpPost("finish")]
|
||||||
public async Task<IActionResult> Finish([FromBody] BattleFinishRequest req)
|
public async Task<IActionResult> Finish([FromBody] BattleFinishRequest req, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _svc.RecordBattleResultAsync(vid, req.BattleResult == 1);
|
var battleCtx = _battleContextStore.TakeFor(vid);
|
||||||
|
bool isWin = req.BattleResult == 1;
|
||||||
|
|
||||||
|
await _historyWriter.RecordAsync(vid, battleCtx, isWin, ct);
|
||||||
|
|
||||||
|
if (battleCtx is { OpponentViewerId: > 0 })
|
||||||
|
{
|
||||||
|
await _playedTogetherWriter.RecordAsync(
|
||||||
|
vid,
|
||||||
|
battleCtx.OpponentViewerId,
|
||||||
|
new BattleParticipationContext(
|
||||||
|
PlayedMode: 0,
|
||||||
|
BattleType: battleCtx.BattleType,
|
||||||
|
DeckFormat: battleCtx.DeckFormat,
|
||||||
|
TwoPickType: battleCtx.TwoPickType),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _svc.RecordBattleResultAsync(vid, isWin);
|
||||||
return Ok(new BattleFinishResponseDto
|
return Ok(new BattleFinishResponseDto
|
||||||
{
|
{
|
||||||
BattleResult = result.BattleResult,
|
BattleResult = result.BattleResult,
|
||||||
|
|||||||
@@ -173,6 +173,59 @@ public class ReplayControllerTests
|
|||||||
Assert.That(infoBody!.ReplayList, Is.Empty);
|
Assert.That(infoBody!.ReplayList, Is.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ArenaTwoPickFinish_writes_history_row_visible_from_ReplayInfo()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL);
|
||||||
|
|
||||||
|
// Seed an active arena run so RecordBattleResultAsync doesn't throw no_active_run.
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
db.Set<ViewerArenaTwoPickRun>().Add(new ViewerArenaTwoPickRun
|
||||||
|
{
|
||||||
|
ViewerId = viewerId,
|
||||||
|
EntryId = 1,
|
||||||
|
ClassId = 1,
|
||||||
|
MaxBattleCount = 5,
|
||||||
|
WinCount = 0,
|
||||||
|
LossCount = 0,
|
||||||
|
ResultListJson = "[]",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var store = factory.Services.GetRequiredService<IBattleContextStore>();
|
||||||
|
store.Set(viewerId, new BattleContext(
|
||||||
|
BattleId: 999_888_777L,
|
||||||
|
BattleType: 4, DeckFormat: 10, TwoPickType: 0,
|
||||||
|
SelfClassId: 1, SelfSubClassId: 0, SelfCharaId: 1, SelfRotationId: "0",
|
||||||
|
OpponentViewerId: 0, OpponentName: "TwoPickBot", OpponentClassId: 2,
|
||||||
|
OpponentSubClassId: 0, OpponentCharaId: 1, OpponentCountryCode: "",
|
||||||
|
OpponentEmblemId: 0, OpponentDegreeId: 0, OpponentRotationId: "0",
|
||||||
|
BattleStartTime: DateTime.UtcNow));
|
||||||
|
|
||||||
|
var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var finishResp = await client.PostAsJsonAsync("/arena_two_pick_battle/finish", new
|
||||||
|
{
|
||||||
|
viewer_id = "0",
|
||||||
|
steam_id = 0,
|
||||||
|
steam_session_ticket = "",
|
||||||
|
battle_result = 1,
|
||||||
|
});
|
||||||
|
Assert.That(finishResp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
var infoBody = await client.PostAsJsonAsync("/replay/info", EmptyBody())
|
||||||
|
.ContinueWith(t => t.Result.Content.ReadFromJsonAsync<ReplayInfoResponseDto>())
|
||||||
|
.Unwrap();
|
||||||
|
Assert.That(infoBody!.ReplayList, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(infoBody.ReplayList[0].OpponentName, Is.EqualTo("TwoPickBot"));
|
||||||
|
}
|
||||||
|
|
||||||
private static ViewerBattleHistory NewRow(long viewerId, long battleId, DateTime createTime) => new()
|
private static ViewerBattleHistory NewRow(long viewerId, long battleId, DateTime createTime) => new()
|
||||||
{
|
{
|
||||||
ViewerId = viewerId,
|
ViewerId = viewerId,
|
||||||
|
|||||||
Reference in New Issue
Block a user