feat(rank): wire RankProgressService into RankBattleController.Finish
Replaces the phase-3 zero stub. Real progression math now lands in the response body (rank / after_battle_point / after_master_point / battle_point / master_point). Format is authored by URL (not derived from BattleContext) so /finish still works when hit without a prior /do_matching — mirrors the client's URL-per-format Wizard/RankBattleFinishTask dispatch (decompile lines 12-35). 3 existing tests updated with real progression assertions; 3 new tests cover D-tier floor loss, format separation, and AI-variant persistence. Full solution suite: 1583/1583 pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ using SVSim.Database.Enums;
|
|||||||
using SVSim.Database.Repositories.Viewer;
|
using SVSim.Database.Repositories.Viewer;
|
||||||
using SVSim.Database.Services.BattleXp;
|
using SVSim.Database.Services.BattleXp;
|
||||||
using SVSim.Database.Services.Friend;
|
using SVSim.Database.Services.Friend;
|
||||||
|
using SVSim.Database.Services.RankProgress;
|
||||||
using SVSim.Database.Services.Replay;
|
using SVSim.Database.Services.Replay;
|
||||||
using SVSim.EmulatedEntrypoint.Constants;
|
using SVSim.EmulatedEntrypoint.Constants;
|
||||||
using SVSim.EmulatedEntrypoint.Extensions;
|
using SVSim.EmulatedEntrypoint.Extensions;
|
||||||
@@ -38,6 +39,7 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
private readonly IPlayedTogetherWriter _playedTogetherWriter;
|
private readonly IPlayedTogetherWriter _playedTogetherWriter;
|
||||||
private readonly IViewerRepository _viewers;
|
private readonly IViewerRepository _viewers;
|
||||||
private readonly IBattleXpService _xp;
|
private readonly IBattleXpService _xp;
|
||||||
|
private readonly IRankProgressService _rankProgress;
|
||||||
private readonly SVSimDbContext _db;
|
private readonly SVSimDbContext _db;
|
||||||
private readonly ILogger<RankBattleController> _log;
|
private readonly ILogger<RankBattleController> _log;
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
IPlayedTogetherWriter playedTogetherWriter,
|
IPlayedTogetherWriter playedTogetherWriter,
|
||||||
IViewerRepository viewers,
|
IViewerRepository viewers,
|
||||||
IBattleXpService xp,
|
IBattleXpService xp,
|
||||||
|
IRankProgressService rankProgress,
|
||||||
SVSimDbContext db,
|
SVSimDbContext db,
|
||||||
ILogger<RankBattleController> log)
|
ILogger<RankBattleController> log)
|
||||||
{
|
{
|
||||||
@@ -63,6 +66,7 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
_playedTogetherWriter = playedTogetherWriter;
|
_playedTogetherWriter = playedTogetherWriter;
|
||||||
_viewers = viewers;
|
_viewers = viewers;
|
||||||
_xp = xp;
|
_xp = xp;
|
||||||
|
_rankProgress = rankProgress;
|
||||||
_db = db;
|
_db = db;
|
||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
@@ -93,17 +97,35 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
public Task<IActionResult> AiStartUnlimited([FromBody] BaseRequest _, CancellationToken ct)
|
public Task<IActionResult> AiStartUnlimited([FromBody] BaseRequest _, CancellationToken ct)
|
||||||
=> AiStartInternal(Format.Unlimited, 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<IActionResult> FinishRotation([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
||||||
|
=> FinishInternal(Format.Rotation, req, ct);
|
||||||
|
|
||||||
|
[HttpPost("/unlimited_rank_battle/finish")]
|
||||||
|
public Task<IActionResult> FinishUnlimited([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
||||||
|
=> FinishInternal(Format.Unlimited, req, ct);
|
||||||
|
|
||||||
|
[HttpPost("/ai_rotation_rank_battle/finish")]
|
||||||
|
public Task<IActionResult> AiFinishRotation([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
||||||
|
=> FinishInternal(Format.Rotation, req, ct);
|
||||||
|
|
||||||
|
[HttpPost("/ai_unlimited_rank_battle/finish")]
|
||||||
|
public Task<IActionResult> AiFinishUnlimited([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
||||||
|
=> FinishInternal(Format.Unlimited, req, ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared finish handler — RankBattleFinishTask parses the same wire shape for
|
/// 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).
|
/// all four URLs. Grants class XP + rank progression (+100 win / -50 loss,
|
||||||
/// Stubbed for Phase 3: echo battle_result, emit zeros elsewhere. Real rank
|
/// tier-floored). Format is baked into the route.
|
||||||
/// progression math is a separate spec.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/rotation_rank_battle/finish")]
|
private async Task<IActionResult> FinishInternal(Format format, RankBattleFinishRequestDto req, CancellationToken ct)
|
||||||
[HttpPost("/unlimited_rank_battle/finish")]
|
|
||||||
[HttpPost("/ai_rotation_rank_battle/finish")]
|
|
||||||
[HttpPost("/ai_unlimited_rank_battle/finish")]
|
|
||||||
public async Task<IActionResult> Finish([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||||
|
|
||||||
@@ -127,10 +149,12 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
int gainXp = 0, totalXp = 0, level = 1;
|
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)
|
if (viewer is not null)
|
||||||
{
|
{
|
||||||
var xp = await _xp.GrantAsync(viewer, req.ClassId, isWin, BattleXpMode.Rank, ct);
|
var xp = await _xp.GrantAsync(viewer, req.ClassId, isWin, BattleXpMode.Rank, ct);
|
||||||
|
rankResult = await _rankProgress.GrantAsync(viewer, format, isWin, ct);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
gainXp = xp.GetXp;
|
gainXp = xp.GetXp;
|
||||||
totalXp = xp.TotalXp;
|
totalXp = xp.TotalXp;
|
||||||
@@ -139,10 +163,15 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new RankBattleFinishResponseDto
|
return Ok(new RankBattleFinishResponseDto
|
||||||
{
|
{
|
||||||
BattleResult = req.BattleResult,
|
BattleResult = req.BattleResult,
|
||||||
GetClassExperience = gainXp,
|
GetClassExperience = gainXp,
|
||||||
ClassExperience = totalXp,
|
ClassExperience = totalXp,
|
||||||
ClassLevel = level,
|
ClassLevel = level,
|
||||||
|
Rank = rankResult.Rank,
|
||||||
|
AfterBattlePoint = rankResult.AfterBattlePoint,
|
||||||
|
AfterMasterPoint = rankResult.AfterMasterPoint,
|
||||||
|
BattlePoint = rankResult.BattlePoint,
|
||||||
|
MasterPoint = rankResult.MasterPoint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ public class Program
|
|||||||
SVSim.Database.Services.Inventory.InventoryService>();
|
SVSim.Database.Services.Inventory.InventoryService>();
|
||||||
builder.Services.AddScoped<SVSim.Database.Services.BattleXp.IBattleXpService,
|
builder.Services.AddScoped<SVSim.Database.Services.BattleXp.IBattleXpService,
|
||||||
SVSim.Database.Services.BattleXp.BattleXpService>();
|
SVSim.Database.Services.BattleXp.BattleXpService>();
|
||||||
|
builder.Services.AddScoped<SVSim.Database.Services.RankProgress.IRankProgressService,
|
||||||
|
SVSim.Database.Services.RankProgress.RankProgressService>();
|
||||||
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IBattlePassRepository,
|
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IBattlePassRepository,
|
||||||
SVSim.Database.Repositories.BattlePass.BattlePassRepository>();
|
SVSim.Database.Repositories.BattlePass.BattlePassRepository>();
|
||||||
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository,
|
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using SVSim.BattleNode.Bridge;
|
|||||||
using SVSim.BattleNode.Sessions;
|
using SVSim.BattleNode.Sessions;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
|
using SVSim.Database.Models;
|
||||||
using SVSim.EmulatedEntrypoint.Services;
|
using SVSim.EmulatedEntrypoint.Services;
|
||||||
using SVSim.UnitTests.Infrastructure;
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
@@ -246,8 +247,12 @@ public class RankBattleControllerTests
|
|||||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||||
var data = doc.RootElement;
|
var data = doc.RootElement;
|
||||||
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));
|
// First-ever win: +100 → Beginner 1 (rank_id=2), Point=100.
|
||||||
Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(0));
|
Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(2));
|
||||||
|
Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(100));
|
||||||
|
Assert.That(data.GetProperty("battle_point").GetInt32(), Is.EqualTo(100));
|
||||||
|
Assert.That(data.GetProperty("after_master_point").GetInt32(), Is.EqualTo(0));
|
||||||
|
Assert.That(data.GetProperty("master_point").GetInt32(), Is.EqualTo(0));
|
||||||
// BattleXpConfig.XpPerWin=200; classexp.csv L1=50, L2=150 → 200 XP crosses both
|
// BattleXpConfig.XpPerWin=200; classexp.csv L1=50, L2=150 → 200 XP crosses both
|
||||||
// thresholds: land at L3 with 0.
|
// thresholds: land at L3 with 0.
|
||||||
Assert.That(data.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(200));
|
Assert.That(data.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(200));
|
||||||
@@ -266,6 +271,11 @@ public class RankBattleControllerTests
|
|||||||
|
|
||||||
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 for rank too: fresh viewer at 0 pts stays at
|
||||||
|
// Beginner 0 (tier floor 0). No point change; +/-0 emitted.
|
||||||
|
Assert.That(doc.RootElement.GetProperty("rank").GetInt32(), Is.EqualTo(1));
|
||||||
|
Assert.That(doc.RootElement.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(0));
|
||||||
|
Assert.That(doc.RootElement.GetProperty("battle_point").GetInt32(), Is.EqualTo(0));
|
||||||
// battle_result==2 is loss-shaped: XpPerLoss=50 exactly meets L1 threshold → L2, Exp=0.
|
// battle_result==2 is loss-shaped: XpPerLoss=50 exactly meets L1 threshold → L2, Exp=0.
|
||||||
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(50));
|
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(50));
|
||||||
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
|
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
|
||||||
@@ -285,9 +295,76 @@ public class RankBattleControllerTests
|
|||||||
using var scope = factory.Services.CreateScope();
|
using var scope = factory.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
var v = await db.Viewers.Include(x => x.Classes).ThenInclude(c => c.Class)
|
var v = await db.Viewers.Include(x => x.Classes).ThenInclude(c => c.Class)
|
||||||
|
.Include(x => x.RankProgress)
|
||||||
.FirstAsync(x => x.Id == viewerId);
|
.FirstAsync(x => x.Id == viewerId);
|
||||||
var cls3 = v.Classes.Single(c => c.Class.Id == 3);
|
var cls3 = v.Classes.Single(c => c.Class.Id == 3);
|
||||||
Assert.That(cls3.Level, Is.EqualTo(3));
|
Assert.That(cls3.Level, Is.EqualTo(3));
|
||||||
Assert.That(cls3.Exp, Is.EqualTo(0));
|
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<SVSimDbContext>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user