feat(battle-xp): IBattleXpService with per-mode overrides and auto level-up

- IBattleXpService.GrantAsync mutates viewer.Classes in place; caller SaveChanges
- Resolves win/loss amount from per-mode override or global XpPerWin/XpPerLoss
- Story mode ignores isWin, uses StoryXpPerClear ?? XpPerWin
- Level-up loop consults curve[row.Level].NecessaryExp; carries overflow
- Saturates at max curve level (excess piles in Exp)
- Unknown classId → no-op returning (0, 0, 1), logs Warning
- 10 unit tests (Moq-backed curve stub); DI registered next to InventoryService
- GameConfigurationJsonbTests baseline updated (19 sections)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-04 08:51:43 -04:00
parent 4541378885
commit 81771bb0a0
5 changed files with 311 additions and 3 deletions

View File

@@ -0,0 +1,79 @@
using Microsoft.Extensions.Logging;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Globals;
namespace SVSim.Database.Services.BattleXp;
public sealed class BattleXpService : IBattleXpService
{
private readonly IGlobalsRepository _globals;
private readonly IGameConfigService _config;
private readonly ILogger<BattleXpService> _log;
// Curve is immutable per boot; cache the first fetch.
private List<ClassExpEntry>? _cachedCurve;
public BattleXpService(IGlobalsRepository globals, IGameConfigService config, ILogger<BattleXpService> log)
{
_globals = globals;
_config = config;
_log = log;
}
public async Task<BattleXpGrantResult> GrantAsync(
Viewer viewer, int classId, bool isWin, BattleXpMode mode, CancellationToken ct = default)
{
var row = viewer.Classes.FirstOrDefault(c => c.Class.Id == classId);
if (row is null)
{
_log.LogWarning(
"BattleXpService: viewer {ViewerId} has no ViewerClassData for classId {ClassId}; skipping grant.",
viewer.Id, classId);
return new BattleXpGrantResult(0, 0, 1);
}
int amount = ResolveAmount(mode, isWin);
row.Exp += amount;
var curve = _cachedCurve ??= await _globals.GetClassExpCurve();
// curve[level] semantics: XP required WHILE AT that level to reach the next
// (matching classexp.csv seed values and LoadController's client-facing shape).
var byLevel = curve.ToDictionary(e => e.Id, e => e.NecessaryExp);
int maxLevel = curve.Count == 0 ? row.Level : curve.Max(e => e.Id);
while (row.Level < maxLevel
&& byLevel.TryGetValue(row.Level, out var needed)
&& row.Exp >= needed)
{
row.Exp -= needed;
row.Level += 1;
}
return new BattleXpGrantResult(amount, row.Exp, row.Level);
}
private int ResolveAmount(BattleXpMode mode, bool isWin)
{
var cfg = _config.Get<BattleXpConfig>();
if (mode == BattleXpMode.Story)
return cfg.StoryXpPerClear ?? cfg.XpPerWin;
int? overrideVal = (mode, isWin) switch
{
(BattleXpMode.Practice, true) => cfg.PracticeXpPerWin,
(BattleXpMode.Practice, false) => cfg.PracticeXpPerLoss,
(BattleXpMode.Rank, true) => cfg.RankXpPerWin,
(BattleXpMode.Rank, false) => cfg.RankXpPerLoss,
(BattleXpMode.Free, true) => cfg.FreeXpPerWin,
(BattleXpMode.Free, false) => cfg.FreeXpPerLoss,
(BattleXpMode.ArenaTwoPick, true) => cfg.ArenaTwoPickXpPerWin,
(BattleXpMode.ArenaTwoPick, false) => cfg.ArenaTwoPickXpPerLoss,
(BattleXpMode.Colosseum, true) => cfg.ColosseumXpPerWin,
(BattleXpMode.Colosseum, false) => cfg.ColosseumXpPerLoss,
_ => null,
};
return overrideVal ?? (isWin ? cfg.XpPerWin : cfg.XpPerLoss);
}
}

View File

@@ -0,0 +1,36 @@
using SVSim.Database.Models;
namespace SVSim.Database.Services.BattleXp;
/// <summary>
/// Amounts returned to callers after a class-XP grant. <see cref="TotalXp"/> and
/// <see cref="Level"/> are POST-grant, POST-level-up (matching the wire shape's
/// <c>class_experience</c> + <c>class_level</c> post-state semantics).
/// </summary>
public sealed record BattleXpGrantResult(int GetXp, int TotalXp, int Level);
public interface IBattleXpService
{
/// <summary>
/// Grants class XP for a battle finish. Caller supplies a viewer loaded via
/// <see cref="Repositories.Viewer.IViewerRepository.LoadForBattleXpGrantAsync"/>
/// (or equivalent, with <c>.Include(v =&gt; v.Classes).ThenInclude(c =&gt; c.Class)</c>).
/// Caller <c>SaveChangesAsync</c> after this returns.
/// <para>
/// Amount resolution: mode-specific config override if non-null, else global
/// <c>XpPerWin</c>/<c>XpPerLoss</c>. Story ignores <paramref name="isWin"/> (always
/// treated as a clear).
/// </para>
/// <para>
/// Level-up: loops on <c>ClassExpEntry</c> curve; <c>row.Exp</c> stores level-relative
/// XP and carries overflow after each level-up. Saturates at curve max level (excess
/// piles in <c>Exp</c>).
/// </para>
/// <para>
/// Guardrail: viewer has no <see cref="ViewerClassData"/> row for
/// <paramref name="classId"/> → returns <c>(0, 0, 1)</c>, no mutation, logs Warning.
/// </para>
/// </summary>
Task<BattleXpGrantResult> GrantAsync(
Viewer viewer, int classId, bool isWin, BattleXpMode mode, CancellationToken ct = default);
}