From 81aac701f446b7d196bf4cdadb4ab8bbbbbc5e9b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 10 Jun 2026 08:15:12 -0400 Subject: [PATCH] feat(replay): wire finish hook for rank-battle family Finish now consumes the stashed BattleContext, records a ViewerBattleHistory row (idempotent + retention-capped), and calls IPlayedTogetherWriter for human PvP (skipped for AI). Co-Authored-By: Claude Opus 4.7 --- .../Controllers/RankBattleController.cs | 31 +++++++- .../Controllers/ReplayControllerTests.cs | 71 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs index f33d275..135ca95 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Sessions; using SVSim.Database.Enums; +using SVSim.Database.Services.Friend; using SVSim.Database.Services.Replay; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Extensions; @@ -30,6 +31,8 @@ public sealed class RankBattleController : ControllerBase private readonly IMatchContextBuilder _ctxBuilder; private readonly IBotRoster _botRoster; private readonly IBattleContextStore _battleContextStore; + private readonly IBattleHistoryWriter _historyWriter; + private readonly IPlayedTogetherWriter _playedTogetherWriter; private readonly ILogger _log; public RankBattleController( @@ -38,6 +41,8 @@ public sealed class RankBattleController : ControllerBase IMatchContextBuilder ctxBuilder, IBotRoster botRoster, IBattleContextStore battleContextStore, + IBattleHistoryWriter historyWriter, + IPlayedTogetherWriter playedTogetherWriter, ILogger log) { _resolver = resolver; @@ -45,6 +50,8 @@ public sealed class RankBattleController : ControllerBase _ctxBuilder = ctxBuilder; _botRoster = botRoster; _battleContextStore = battleContextStore; + _historyWriter = historyWriter; + _playedTogetherWriter = playedTogetherWriter; _log = log; } @@ -84,9 +91,29 @@ public sealed class RankBattleController : ControllerBase [HttpPost("/unlimited_rank_battle/finish")] [HttpPost("/ai_rotation_rank_battle/finish")] [HttpPost("/ai_unlimited_rank_battle/finish")] - public IActionResult Finish([FromBody] RankBattleFinishRequestDto req) + public async Task Finish([FromBody] RankBattleFinishRequestDto req, CancellationToken ct) { - if (!TryGetViewerId(out var _)) return Unauthorized(); + if (!TryGetViewerId(out var vid)) return Unauthorized(); + + var ctx = _battleContextStore.TakeFor(vid); + bool isWin = req.BattleResult == 1; + + await _historyWriter.RecordAsync(vid, ctx, isWin, ct); + + // Played-together only fires for human PvP. AI bots have OpponentViewerId=0. + if (ctx is { OpponentViewerId: > 0 }) + { + await _playedTogetherWriter.RecordAsync( + vid, + ctx.OpponentViewerId, + new BattleParticipationContext( + PlayedMode: 0, + BattleType: ctx.BattleType, + DeckFormat: ctx.DeckFormat, + TwoPickType: ctx.TwoPickType), + ct); + } + return Ok(new RankBattleFinishResponseDto { BattleResult = req.BattleResult, diff --git a/SVSim.UnitTests/Controllers/ReplayControllerTests.cs b/SVSim.UnitTests/Controllers/ReplayControllerTests.cs index da63f6f..658608a 100644 --- a/SVSim.UnitTests/Controllers/ReplayControllerTests.cs +++ b/SVSim.UnitTests/Controllers/ReplayControllerTests.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SVSim.Database; using SVSim.Database.Models; +using SVSim.Database.Services.Replay; using SVSim.EmulatedEntrypoint.Models.Dtos.Replay; using SVSim.UnitTests.Infrastructure; @@ -102,6 +103,76 @@ public class ReplayControllerTests Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); } + [Test] + public async Task AiRankFinish_writes_history_row_visible_from_ReplayInfo() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL); + + // Inject a pre-stashed BattleContext as if /ai_rotation_rank_battle/start had run. + var store = factory.Services.GetRequiredService(); + store.Set(viewerId, new BattleContext( + BattleId: 234_471_983_876L, + BattleType: 2, DeckFormat: 0, TwoPickType: 0, + SelfClassId: 8, SelfSubClassId: 0, SelfCharaId: 8, SelfRotationId: "0", + OpponentViewerId: 0, OpponentName: "BotName", OpponentClassId: 5, + OpponentSubClassId: 0, OpponentCharaId: 805, OpponentCountryCode: "", + OpponentEmblemId: 721_341_010L, OpponentDegreeId: 120_023L, + OpponentRotationId: "0", + BattleStartTime: new DateTime(2026, 6, 4, 17, 13, 13, DateTimeKind.Utc))); + + var client = factory.CreateAuthenticatedClient(viewerId); + + var finishResp = await client.PostAsJsonAsync("/ai_rotation_rank_battle/finish", new + { + viewer_id = "0", + steam_id = 0, + steam_session_ticket = "", + battle_result = 1, // win + class_id = 8, + total_turn = 7, + evolve_count = 1, + enemy_evolve_count = 0, + sdtrb = 0, + }); + 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)); + var row = infoBody.ReplayList[0]; + Assert.That(row.BattleId, Is.EqualTo("234471983876")); + Assert.That(row.OpponentName, Is.EqualTo("BotName")); + Assert.That(row.IsWin, Is.EqualTo("1")); + Assert.That(row.OpponentEmblemId, Is.EqualTo("721341010")); + } + + [Test] + public async Task AiRankFinish_with_no_stashed_context_does_not_crash() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL); + + var client = factory.CreateAuthenticatedClient(viewerId); + + var finishResp = await client.PostAsJsonAsync("/ai_rotation_rank_battle/finish", new + { + viewer_id = "0", + steam_id = 0, + steam_session_ticket = "", + battle_result = 0, + class_id = 1, total_turn = 1, evolve_count = 0, enemy_evolve_count = 0, sdtrb = 0, + }); + 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, Is.Empty); + } + private static ViewerBattleHistory NewRow(long viewerId, long battleId, DateTime createTime) => new() { ViewerId = viewerId,