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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-04 08:59:01 -04:00
parent c3da6079b2
commit 1e9693e56d
2 changed files with 59 additions and 5 deletions

View File

@@ -2,7 +2,10 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions;
using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services.BattleXp;
using SVSim.Database.Services.Friend; using SVSim.Database.Services.Friend;
using SVSim.Database.Services.Replay; using SVSim.Database.Services.Replay;
using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Constants;
@@ -33,6 +36,9 @@ public sealed class RankBattleController : ControllerBase
private readonly IBattleContextStore _battleContextStore; private readonly IBattleContextStore _battleContextStore;
private readonly IBattleHistoryWriter _historyWriter; private readonly IBattleHistoryWriter _historyWriter;
private readonly IPlayedTogetherWriter _playedTogetherWriter; private readonly IPlayedTogetherWriter _playedTogetherWriter;
private readonly IViewerRepository _viewers;
private readonly IBattleXpService _xp;
private readonly SVSimDbContext _db;
private readonly ILogger<RankBattleController> _log; private readonly ILogger<RankBattleController> _log;
public RankBattleController( public RankBattleController(
@@ -43,6 +49,9 @@ public sealed class RankBattleController : ControllerBase
IBattleContextStore battleContextStore, IBattleContextStore battleContextStore,
IBattleHistoryWriter historyWriter, IBattleHistoryWriter historyWriter,
IPlayedTogetherWriter playedTogetherWriter, IPlayedTogetherWriter playedTogetherWriter,
IViewerRepository viewers,
IBattleXpService xp,
SVSimDbContext db,
ILogger<RankBattleController> log) ILogger<RankBattleController> log)
{ {
_resolver = resolver; _resolver = resolver;
@@ -52,6 +61,9 @@ public sealed class RankBattleController : ControllerBase
_battleContextStore = battleContextStore; _battleContextStore = battleContextStore;
_historyWriter = historyWriter; _historyWriter = historyWriter;
_playedTogetherWriter = playedTogetherWriter; _playedTogetherWriter = playedTogetherWriter;
_viewers = viewers;
_xp = xp;
_db = db;
_log = log; _log = log;
} }
@@ -114,10 +126,23 @@ public sealed class RankBattleController : ControllerBase
ct); 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 return Ok(new RankBattleFinishResponseDto
{ {
BattleResult = req.BattleResult, BattleResult = req.BattleResult,
// All other fields default to 0 in the DTO (ClassLevel defaults to 1). GetClassExperience = gainXp,
ClassExperience = totalXp,
ClassLevel = level,
}); });
} }

View File

@@ -1,9 +1,11 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework; using NUnit.Framework;
using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions;
using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure; using SVSim.UnitTests.Infrastructure;
@@ -231,13 +233,14 @@ public class RankBattleControllerTests
} }
[Test] [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(); await using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync(); var viewerId = await factory.SeedViewerAsync();
var client = factory.CreateAuthenticatedClient(viewerId); 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); Assert.That(resp.IsSuccessStatusCode, Is.True);
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); 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("battle_result").GetInt32(), Is.EqualTo(1));
Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(0)); Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(0));
Assert.That(data.GetProperty("after_battle_point").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] [Test]
@@ -255,9 +261,32 @@ public class RankBattleControllerTests
var viewerId = await factory.SeedViewerAsync(); var viewerId = await factory.SeedViewerAsync();
var client = factory.CreateAuthenticatedClient(viewerId); 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()); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
Assert.That(doc.RootElement.GetProperty("battle_result").GetInt32(), Is.EqualTo(2)); 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<SVSimDbContext>();
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));
} }
} }