From 09960742878ad897581a9cb3b849f1f6f143ad6c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 10 Jun 2026 08:22:19 -0400 Subject: [PATCH] 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 --- .../ArenaTwoPickBattleController.cs | 69 ++++++++++++++++++- .../Controllers/ReplayControllerTests.cs | 53 ++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs index d43af87..3db879d 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs @@ -1,5 +1,9 @@ using Microsoft.AspNetCore.Mvc; 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.Models.Dtos.Requests.ArenaTwoPick; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; @@ -13,15 +17,24 @@ public class ArenaTwoPickBattleController : SVSimController private readonly IArenaTwoPickService _svc; private readonly IMatchContextBuilder _matchContextBuilder; private readonly IMatchingResolver _resolver; + private readonly IBattleContextStore _battleContextStore; + private readonly IBattleHistoryWriter _historyWriter; + private readonly IPlayedTogetherWriter _playedTogetherWriter; public ArenaTwoPickBattleController( IArenaTwoPickService svc, IMatchContextBuilder matchContextBuilder, - IMatchingResolver resolver) + IMatchingResolver resolver, + IBattleContextStore battleContextStore, + IBattleHistoryWriter historyWriter, + IPlayedTogetherWriter playedTogetherWriter) { _svc = svc; _matchContextBuilder = matchContextBuilder; _resolver = resolver; + _battleContextStore = battleContextStore; + _historyWriter = historyWriter; + _playedTogetherWriter = playedTogetherWriter; } [HttpPost("do_matching")] @@ -34,6 +47,38 @@ public class ArenaTwoPickBattleController : SVSimController { var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid); 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 { MatchingState = r.MatchingState, @@ -48,12 +93,30 @@ public class ArenaTwoPickBattleController : SVSimController } [HttpPost("finish")] - public async Task Finish([FromBody] BattleFinishRequest req) + public async Task Finish([FromBody] BattleFinishRequest req, CancellationToken ct = default) { if (!TryGetViewerId(out var vid)) return Unauthorized(); 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 { BattleResult = result.BattleResult, diff --git a/SVSim.UnitTests/Controllers/ReplayControllerTests.cs b/SVSim.UnitTests/Controllers/ReplayControllerTests.cs index 658608a..9981ad6 100644 --- a/SVSim.UnitTests/Controllers/ReplayControllerTests.cs +++ b/SVSim.UnitTests/Controllers/ReplayControllerTests.cs @@ -173,6 +173,59 @@ public class ReplayControllerTests 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(); + db.Set().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(); + 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()) + .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() { ViewerId = viewerId,