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:
@@ -1,7 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SVSim.Database;
|
||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
using SVSim.Database.Repositories.Globals;
|
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;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
|
||||||
@@ -16,15 +19,24 @@ public class PracticeController : SVSimController
|
|||||||
private readonly IGlobalsRepository _globalsRepository;
|
private readonly IGlobalsRepository _globalsRepository;
|
||||||
private readonly IMissionProgressService _missionProgress;
|
private readonly IMissionProgressService _missionProgress;
|
||||||
private readonly IDeckListBuilder _deckListBuilder;
|
private readonly IDeckListBuilder _deckListBuilder;
|
||||||
|
private readonly IViewerRepository _viewers;
|
||||||
|
private readonly IBattleXpService _xp;
|
||||||
|
private readonly SVSimDbContext _db;
|
||||||
|
|
||||||
public PracticeController(
|
public PracticeController(
|
||||||
IGlobalsRepository globalsRepository,
|
IGlobalsRepository globalsRepository,
|
||||||
IMissionProgressService missionProgress,
|
IMissionProgressService missionProgress,
|
||||||
IDeckListBuilder deckListBuilder)
|
IDeckListBuilder deckListBuilder,
|
||||||
|
IViewerRepository viewers,
|
||||||
|
IBattleXpService xp,
|
||||||
|
SVSimDbContext db)
|
||||||
{
|
{
|
||||||
_globalsRepository = globalsRepository;
|
_globalsRepository = globalsRepository;
|
||||||
_missionProgress = missionProgress;
|
_missionProgress = missionProgress;
|
||||||
_deckListBuilder = deckListBuilder;
|
_deckListBuilder = deckListBuilder;
|
||||||
|
_viewers = viewers;
|
||||||
|
_xp = xp;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -79,17 +91,29 @@ public class PracticeController : SVSimController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// /practice/finish — accept the recovery_data blob without validation; return zero
|
/// /practice/finish — accept the recovery_data blob without validation; grant class XP
|
||||||
/// XP / no rewards. Class XP bookkeeping is deferred until a per-class XP store exists.
|
/// (win or loss) via <see cref="IBattleXpService"/> and return the post-state totals.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("finish")]
|
[HttpPost("finish")]
|
||||||
public async Task<PracticeFinishResponse> Finish(PracticeFinishRequest request)
|
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
|
// 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 /
|
// 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
|
// difficulty here, so we emit numeric forms. Bridging numeric→name to match captured
|
||||||
// catalog rows is a follow-up; the infrastructure is in place.
|
// 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[]
|
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
|
return new PracticeFinishResponse
|
||||||
{
|
{
|
||||||
GetClassExperience = 0,
|
GetClassExperience = gainXp,
|
||||||
ClassExperience = 0,
|
ClassExperience = totalXp,
|
||||||
ClassLevel = 1,
|
ClassLevel = level,
|
||||||
AchievedInfo = new Dictionary<string, object>(),
|
AchievedInfo = new Dictionary<string, object>(),
|
||||||
RewardList = new List<Models.Dtos.Common.Reward>()
|
RewardList = new List<Models.Dtos.Common.Reward>()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ public class PracticeControllerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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();
|
using var factory = new SVSimTestFactory();
|
||||||
long viewerId = await factory.SeedViewerAsync();
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
@@ -218,8 +218,44 @@ public class PracticeControllerTests
|
|||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(body);
|
using var doc = JsonDocument.Parse(body);
|
||||||
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(0));
|
// BattleXpConfig.XpPerWin default = 100. classexp.csv seeds L1=50, L2=150, so 100 XP
|
||||||
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
|
// 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));
|
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<SVSimDbContext>();
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user