diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs index bd03c9bb..0239c2ea 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs @@ -7,6 +7,7 @@ using SVSim.Database.Enums; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services.BattleXp; using SVSim.Database.Services.Friend; +using SVSim.Database.Services.RankProgress; using SVSim.Database.Services.Replay; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Extensions; @@ -38,6 +39,7 @@ public sealed class RankBattleController : ControllerBase private readonly IPlayedTogetherWriter _playedTogetherWriter; private readonly IViewerRepository _viewers; private readonly IBattleXpService _xp; + private readonly IRankProgressService _rankProgress; private readonly SVSimDbContext _db; private readonly ILogger _log; @@ -51,6 +53,7 @@ public sealed class RankBattleController : ControllerBase IPlayedTogetherWriter playedTogetherWriter, IViewerRepository viewers, IBattleXpService xp, + IRankProgressService rankProgress, SVSimDbContext db, ILogger log) { @@ -63,6 +66,7 @@ public sealed class RankBattleController : ControllerBase _playedTogetherWriter = playedTogetherWriter; _viewers = viewers; _xp = xp; + _rankProgress = rankProgress; _db = db; _log = log; } @@ -93,17 +97,35 @@ public sealed class RankBattleController : ControllerBase public Task AiStartUnlimited([FromBody] BaseRequest _, CancellationToken ct) => AiStartInternal(Format.Unlimited, ct); + // Finish routes route by URL to the shared handler with the format baked in — the URL + // is authoritative (mirrors DoMatchingRotation/DoMatchingUnlimited). Deriving format + // from the BattleContext is fragile: the client may reach /finish without a prior + // /do_matching (e.g. reconnect, replay-of-log flows), and we still need rank + // progression to land in the right format bucket. Client-side, RankBattleFinishTask + // picks the URL by (Data.CurrentFormat, IsAINetwork) — see decompile lines 12-35. + + [HttpPost("/rotation_rank_battle/finish")] + public Task FinishRotation([FromBody] RankBattleFinishRequestDto req, CancellationToken ct) + => FinishInternal(Format.Rotation, req, ct); + + [HttpPost("/unlimited_rank_battle/finish")] + public Task FinishUnlimited([FromBody] RankBattleFinishRequestDto req, CancellationToken ct) + => FinishInternal(Format.Unlimited, req, ct); + + [HttpPost("/ai_rotation_rank_battle/finish")] + public Task AiFinishRotation([FromBody] RankBattleFinishRequestDto req, CancellationToken ct) + => FinishInternal(Format.Rotation, req, ct); + + [HttpPost("/ai_unlimited_rank_battle/finish")] + public Task AiFinishUnlimited([FromBody] RankBattleFinishRequestDto req, CancellationToken ct) + => FinishInternal(Format.Unlimited, req, ct); + /// /// Shared finish handler — RankBattleFinishTask parses the same wire shape for - /// all four URLs and routes server-side by URL (vs IsAINetwork flag in the client). - /// Stubbed for Phase 3: echo battle_result, emit zeros elsewhere. Real rank - /// progression math is a separate spec. + /// all four URLs. Grants class XP + rank progression (+100 win / -50 loss, + /// tier-floored). Format is baked into the route. /// - [HttpPost("/rotation_rank_battle/finish")] - [HttpPost("/unlimited_rank_battle/finish")] - [HttpPost("/ai_rotation_rank_battle/finish")] - [HttpPost("/ai_unlimited_rank_battle/finish")] - public async Task Finish([FromBody] RankBattleFinishRequestDto req, CancellationToken ct) + private async Task FinishInternal(Format format, RankBattleFinishRequestDto req, CancellationToken ct) { if (!TryGetViewerId(out var vid)) return Unauthorized(); @@ -127,10 +149,12 @@ public sealed class RankBattleController : ControllerBase } int gainXp = 0, totalXp = 0, level = 1; - var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct); + var rankResult = new RankProgressResult(0, 0, 0, 0, 0, false, false); + var viewer = await _viewers.LoadForRankProgressAsync(vid, ct); if (viewer is not null) { var xp = await _xp.GrantAsync(viewer, req.ClassId, isWin, BattleXpMode.Rank, ct); + rankResult = await _rankProgress.GrantAsync(viewer, format, isWin, ct); await _db.SaveChangesAsync(ct); gainXp = xp.GetXp; totalXp = xp.TotalXp; @@ -139,10 +163,15 @@ public sealed class RankBattleController : ControllerBase return Ok(new RankBattleFinishResponseDto { - BattleResult = req.BattleResult, + BattleResult = req.BattleResult, GetClassExperience = gainXp, - ClassExperience = totalXp, - ClassLevel = level, + ClassExperience = totalXp, + ClassLevel = level, + Rank = rankResult.Rank, + AfterBattlePoint = rankResult.AfterBattlePoint, + AfterMasterPoint = rankResult.AfterMasterPoint, + BattlePoint = rankResult.BattlePoint, + MasterPoint = rankResult.MasterPoint, }); } diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 1d5f10b7..84ee1099 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -101,6 +101,8 @@ public class Program SVSim.Database.Services.Inventory.InventoryService>(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); var v = await db.Viewers.Include(x => x.Classes).ThenInclude(c => c.Class) + .Include(x => x.RankProgress) .FirstAsync(x => x.Id == viewerId); var cls3 = v.Classes.Single(c => c.Class.Id == 3); Assert.That(cls3.Level, Is.EqualTo(3)); Assert.That(cls3.Exp, Is.EqualTo(0)); + // Rank progress persisted under Unlimited with +100. + var rp = v.RankProgress.Single(r => r.Format == Format.Unlimited); + Assert.That(rp.Point, Is.EqualTo(100)); + Assert.That(rp.MasterPoint, Is.EqualTo(0)); + } + + [Test] + public async Task Finish_loss_at_D_tier_stays_at_D_floor() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(); + + // Preload viewer with Point=1200 (D0 entry) in Rotation. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId); + v.RankProgress.Add(new ViewerRankProgress + { + Format = Format.Rotation, Point = 1200, MasterPoint = 0, + }); + await db.SaveChangesAsync(); + } + + var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsJsonAsync( + "/rotation_rank_battle/finish", FinishBody(battleResult: 0, classId: 1)); + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var data = doc.RootElement; + Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(5)); // still D0 + Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(1200)); // floored + Assert.That(data.GetProperty("battle_point").GetInt32(), Is.EqualTo(0)); // no drop + } + + [Test] + public async Task Finish_rotation_and_unlimited_progress_independently() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(); + var client = factory.CreateAuthenticatedClient(viewerId); + + await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 1, classId: 1)); + await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 1, classId: 1)); + await client.PostAsJsonAsync("/unlimited_rank_battle/finish", FinishBody(battleResult: 1, classId: 1)); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId); + Assert.That(v.RankProgress.Single(r => r.Format == Format.Rotation).Point, Is.EqualTo(200)); + Assert.That(v.RankProgress.Single(r => r.Format == Format.Unlimited).Point, Is.EqualTo(100)); + } + + [Test] + public async Task Finish_ai_variant_persists_progress_same_as_human_variant() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(); + var client = factory.CreateAuthenticatedClient(viewerId); + + await client.PostAsJsonAsync("/ai_unlimited_rank_battle/finish", FinishBody(battleResult: 1, classId: 1)); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId); + Assert.That(v.RankProgress.Single(r => r.Format == Format.Unlimited).Point, Is.EqualTo(100)); } }