feat(battle-xp): wire /practice/finish through IBattleXpService

- Practice finish now grants win/loss XP via IBattleXpService.GrantAsync
  with BattleXpMode.Practice. Response fields (get_class_experience,
  class_experience, class_level) reflect post-grant totals.
- Uses req.ClassId from wire; no BattleContext lookup needed.
- Test asserts default XpPerWin=100 crosses classexp.csv L1=50 threshold
  → Level=2 + Exp=50 carry, persists to Viewer.Classes.
- Added loss-XP test asserting default XpPerLoss=25 stays under threshold.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-04 08:56:59 -04:00
parent f85f221bfa
commit c3da6079b2
2 changed files with 81 additions and 10 deletions

View File

@@ -1,7 +1,10 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services.BattleXp;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
@@ -16,15 +19,24 @@ public class PracticeController : SVSimController
private readonly IGlobalsRepository _globalsRepository;
private readonly IMissionProgressService _missionProgress;
private readonly IDeckListBuilder _deckListBuilder;
private readonly IViewerRepository _viewers;
private readonly IBattleXpService _xp;
private readonly SVSimDbContext _db;
public PracticeController(
IGlobalsRepository globalsRepository,
IMissionProgressService missionProgress,
IDeckListBuilder deckListBuilder)
IDeckListBuilder deckListBuilder,
IViewerRepository viewers,
IBattleXpService xp,
SVSimDbContext db)
{
_globalsRepository = globalsRepository;
_missionProgress = missionProgress;
_deckListBuilder = deckListBuilder;
_viewers = viewers;
_xp = xp;
_db = db;
}
/// <summary>
@@ -79,17 +91,29 @@ public class PracticeController : SVSimController
}
/// <summary>
/// /practice/finish — accept the recovery_data blob without validation; return zero
/// XP / no rewards. Class XP bookkeeping is deferred until a per-class XP store exists.
/// /practice/finish — accept the recovery_data blob without validation; grant class XP
/// (win or loss) via <see cref="IBattleXpService"/> and return the post-state totals.
/// </summary>
[HttpPost("finish")]
public async Task<PracticeFinishResponse> Finish(PracticeFinishRequest request)
{
bool isWin = request.IsWin == 1;
if (!TryGetViewerId(out long viewerId))
{
return new PracticeFinishResponse
{
ClassLevel = 1,
AchievedInfo = new Dictionary<string, object>(),
RewardList = new List<Models.Dtos.Common.Reward>(),
};
}
// Mission/achievement progress hook. Catalog rows for practice_win achievements use
// opponent NAMES (e.g. "practice_win:elite:arisa") — we only have numeric class_id /
// difficulty here, so we emit numeric forms. Bridging numeric→name to match captured
// catalog rows is a follow-up; the infrastructure is in place.
if (request.IsWin == 1 && TryGetViewerId(out long viewerId))
if (isWin)
{
await _missionProgress.RecordEventAsync(viewerId, new[]
{
@@ -99,11 +123,22 @@ public class PracticeController : SVSimController
});
}
int gainXp = 0, totalXp = 0, level = 1;
var viewer = await _viewers.LoadForBattleXpGrantAsync(viewerId);
if (viewer is not null)
{
var xp = await _xp.GrantAsync(viewer, request.ClassId, isWin, BattleXpMode.Practice);
await _db.SaveChangesAsync();
gainXp = xp.GetXp;
totalXp = xp.TotalXp;
level = xp.Level == 0 ? 1 : xp.Level;
}
return new PracticeFinishResponse
{
GetClassExperience = 0,
ClassExperience = 0,
ClassLevel = 1,
GetClassExperience = gainXp,
ClassExperience = totalXp,
ClassLevel = level,
AchievedInfo = new Dictionary<string, object>(),
RewardList = new List<Models.Dtos.Common.Reward>()
};