diff --git a/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs b/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs index 0f473201..6d5b0bef 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs @@ -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; } /// @@ -79,17 +91,29 @@ public class PracticeController : SVSimController } /// - /// /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 and return the post-state totals. /// [HttpPost("finish")] public async Task Finish(PracticeFinishRequest request) { + bool isWin = request.IsWin == 1; + + if (!TryGetViewerId(out long viewerId)) + { + return new PracticeFinishResponse + { + ClassLevel = 1, + AchievedInfo = new Dictionary(), + RewardList = new List(), + }; + } + // 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(), RewardList = new List() }; diff --git a/SVSim.UnitTests/Controllers/PracticeControllerTests.cs b/SVSim.UnitTests/Controllers/PracticeControllerTests.cs index 2b8bba48..9b27d3b1 100644 --- a/SVSim.UnitTests/Controllers/PracticeControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PracticeControllerTests.cs @@ -197,7 +197,7 @@ public class PracticeControllerTests } [Test] - public async Task Finish_accepts_any_recovery_data_returns_zero_xp() + public async Task Finish_win_grants_class_xp_and_persists_level_up() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); @@ -218,8 +218,44 @@ public class PracticeControllerTests Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); using var doc = JsonDocument.Parse(body); - Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(0)); - Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0)); + // BattleXpConfig.XpPerWin default = 100. classexp.csv seeds L1=50, L2=150, so 100 XP + // crosses L1's threshold: land at Level=2 with 50 carry. + Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(100)); + Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(50)); + Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(2)); Assert.That(doc.RootElement.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0)); + + // Persistence check — reload viewer and confirm the ViewerClassData row moved. + 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 cls1 = v.Classes.Single(c => c.Class.Id == 1); + Assert.That(cls1.Level, Is.EqualTo(2)); + Assert.That(cls1.Exp, Is.EqualTo(50)); + } + + [Test] + public async Task Finish_loss_grants_default_loss_xp() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var finishJson = + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"is_win":0,"evolve_count":0,"total_turn":3,"enemy_class_id":3,"difficulty":1,"deck_format":1,"class_id":1,"recovery_data":"{}"}"""; + + var response = await client.PostAsync("/practice/finish", + new StringContent(finishJson, Encoding.UTF8, "application/json")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + // BattleXpConfig.XpPerLoss default = 25. classexp.csv seeds L1=50, so 25 XP stays + // under threshold: Level=1, Exp=25. + 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)); } }