From 1e9693e56d1750e71de639ad5da580e452e840d4 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 4 Jul 2026 08:59:01 -0400 Subject: [PATCH] feat(battle-xp): wire rank + AI-rank /finish through IBattleXpService - RankBattleController.Finish (covers all 4 URLs: rotation, unlimited, ai_rotation, ai_unlimited) now grants win/loss XP via IBattleXpService with BattleXpMode.Rank. Uses req.ClassId from wire. - Tests updated: AI-variant win asserts new XP values; consistency-loss asserts loss XP. Added persistence test on /unlimited_rank_battle/finish. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/RankBattleController.cs | 27 +++++++++++++- .../Controllers/RankBattleControllerTests.cs | 37 +++++++++++++++++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs index 3f9d2c07..bd03c9bb 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs @@ -2,7 +2,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Sessions; +using SVSim.Database; using SVSim.Database.Enums; +using SVSim.Database.Repositories.Viewer; +using SVSim.Database.Services.BattleXp; using SVSim.Database.Services.Friend; using SVSim.Database.Services.Replay; using SVSim.EmulatedEntrypoint.Constants; @@ -33,6 +36,9 @@ public sealed class RankBattleController : ControllerBase private readonly IBattleContextStore _battleContextStore; private readonly IBattleHistoryWriter _historyWriter; private readonly IPlayedTogetherWriter _playedTogetherWriter; + private readonly IViewerRepository _viewers; + private readonly IBattleXpService _xp; + private readonly SVSimDbContext _db; private readonly ILogger _log; public RankBattleController( @@ -43,6 +49,9 @@ public sealed class RankBattleController : ControllerBase IBattleContextStore battleContextStore, IBattleHistoryWriter historyWriter, IPlayedTogetherWriter playedTogetherWriter, + IViewerRepository viewers, + IBattleXpService xp, + SVSimDbContext db, ILogger log) { _resolver = resolver; @@ -52,6 +61,9 @@ public sealed class RankBattleController : ControllerBase _battleContextStore = battleContextStore; _historyWriter = historyWriter; _playedTogetherWriter = playedTogetherWriter; + _viewers = viewers; + _xp = xp; + _db = db; _log = log; } @@ -114,10 +126,23 @@ public sealed class RankBattleController : ControllerBase ct); } + int gainXp = 0, totalXp = 0, level = 1; + var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct); + if (viewer is not null) + { + var xp = await _xp.GrantAsync(viewer, req.ClassId, isWin, BattleXpMode.Rank, ct); + await _db.SaveChangesAsync(ct); + gainXp = xp.GetXp; + totalXp = xp.TotalXp; + level = xp.Level == 0 ? 1 : xp.Level; + } + return Ok(new RankBattleFinishResponseDto { BattleResult = req.BattleResult, - // All other fields default to 0 in the DTO (ClassLevel defaults to 1). + GetClassExperience = gainXp, + ClassExperience = totalXp, + ClassLevel = level, }); } diff --git a/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs b/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs index 919cd6ac..46950222 100644 --- a/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs +++ b/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs @@ -1,9 +1,11 @@ using System.Net.Http.Json; using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Sessions; +using SVSim.Database; using SVSim.Database.Enums; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -231,13 +233,14 @@ public class RankBattleControllerTests } [Test] - public async Task Finish_emits_stubbed_zeros_with_battle_result_echo() + public async Task Finish_grants_win_xp_via_ai_variant() { await using var factory = new SVSimTestFactory(); var viewerId = await factory.SeedViewerAsync(); var client = factory.CreateAuthenticatedClient(viewerId); - var resp = await client.PostAsJsonAsync("/ai_rotation_rank_battle/finish", FinishBody(battleResult: 1)); + var resp = await client.PostAsJsonAsync( + "/ai_rotation_rank_battle/finish", FinishBody(battleResult: 1, classId: 1)); Assert.That(resp.IsSuccessStatusCode, Is.True); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); @@ -245,7 +248,10 @@ public class RankBattleControllerTests Assert.That(data.GetProperty("battle_result").GetInt32(), Is.EqualTo(1)); Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(0)); Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(0)); - Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(1)); + // BattleXpConfig.XpPerWin=100, classexp.csv L1=50 → land at L2 with 50 carry. + Assert.That(data.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(100)); + Assert.That(data.GetProperty("class_experience").GetInt32(), Is.EqualTo(50)); + Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(2)); } [Test] @@ -255,9 +261,32 @@ public class RankBattleControllerTests var viewerId = await factory.SeedViewerAsync(); var client = factory.CreateAuthenticatedClient(viewerId); - var resp = await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 2)); + var resp = await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 2, classId: 1)); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); Assert.That(doc.RootElement.GetProperty("battle_result").GetInt32(), Is.EqualTo(2)); + // battle_result==2 is loss-shaped: XpPerLoss=25 (< L1 threshold=50). + Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(25)); + Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(25)); + Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(1)); + } + + [Test] + public async Task Finish_persists_class_xp_to_viewer() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(); + var client = factory.CreateAuthenticatedClient(viewerId); + + await client.PostAsJsonAsync( + "/unlimited_rank_battle/finish", FinishBody(battleResult: 1, classId: 3)); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.Include(x => x.Classes).ThenInclude(c => c.Class) + .FirstAsync(x => x.Id == viewerId); + var cls3 = v.Classes.Single(c => c.Class.Id == 3); + Assert.That(cls3.Level, Is.EqualTo(2)); + Assert.That(cls3.Exp, Is.EqualTo(50)); } }