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:
79
SVSim.Database/Services/BattleXp/BattleXpService.cs
Normal file
79
SVSim.Database/Services/BattleXp/BattleXpService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
36
SVSim.Database/Services/BattleXp/IBattleXpService.cs
Normal file
36
SVSim.Database/Services/BattleXp/IBattleXpService.cs
Normal 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 => v.Classes).ThenInclude(c => 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);
|
||||
}
|
||||
@@ -99,6 +99,8 @@ public class Program
|
||||
builder.Services.AddScoped<IGachaPointService, GachaPointService>();
|
||||
builder.Services.AddScoped<SVSim.Database.Services.Inventory.IInventoryService,
|
||||
SVSim.Database.Services.Inventory.InventoryService>();
|
||||
builder.Services.AddScoped<SVSim.Database.Services.BattleXp.IBattleXpService,
|
||||
SVSim.Database.Services.BattleXp.BattleXpService>();
|
||||
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IBattlePassRepository,
|
||||
SVSim.Database.Repositories.BattlePass.BattlePassRepository>();
|
||||
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository,
|
||||
|
||||
@@ -25,16 +25,16 @@ public class GameConfigurationJsonbTests
|
||||
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
|
||||
var byName = rows.ToDictionary(r => r.SectionName);
|
||||
|
||||
// One row per [ConfigSection]-marked POCO (18 sections today: Player, DefaultGrants,
|
||||
// One row per [ConfigSection]-marked POCO (19 sections today: Player, DefaultGrants,
|
||||
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig,
|
||||
// Freeplay, ArenaTwoPick, Matching, CardMasterConfig, ColosseumSeason, ColosseumRounds,
|
||||
// LoginBonus, Guild, SkipTutorial).
|
||||
// LoginBonus, Guild, SkipTutorial, BattleXp).
|
||||
Assert.That(byName.Keys, Is.EquivalentTo(new[]
|
||||
{
|
||||
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
|
||||
"MyRotationSchedule", "Story", "ResourceConfig", "Freeplay", "ArenaTwoPick", "Matching",
|
||||
"CardMasterConfig", "ColosseumSeason", "ColosseumRounds", "LoginBonus", "Guild",
|
||||
"SkipTutorial",
|
||||
"SkipTutorial", "BattleXp",
|
||||
}));
|
||||
|
||||
var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!;
|
||||
|
||||
191
SVSim.UnitTests/Services/BattleXpServiceTests.cs
Normal file
191
SVSim.UnitTests/Services/BattleXpServiceTests.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.BattleXp;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class BattleXpServiceTests
|
||||
{
|
||||
private sealed class FakeConfig : IGameConfigService
|
||||
{
|
||||
private readonly BattleXpConfig _cfg;
|
||||
public FakeConfig(BattleXpConfig cfg) { _cfg = cfg; }
|
||||
public T Get<T>() where T : class, new() => (T)(object)_cfg;
|
||||
}
|
||||
|
||||
private static IGlobalsRepository CurveRepo(params (int Level, int NecessaryExp)[] rows)
|
||||
{
|
||||
var mock = new Mock<IGlobalsRepository>(MockBehavior.Strict);
|
||||
mock.Setup(x => x.GetClassExpCurve()).ReturnsAsync(
|
||||
rows.Select(r => new ClassExpEntry { Id = r.Level, NecessaryExp = r.NecessaryExp }).ToList());
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static Viewer NewViewerWithClass(int classId, int level = 1, int exp = 0)
|
||||
{
|
||||
var cls = new ClassEntry { Id = classId, Name = $"Class{classId}" };
|
||||
return new Viewer
|
||||
{
|
||||
Id = 1,
|
||||
DisplayName = "v",
|
||||
Classes = { new ViewerClassData { Class = cls, Level = level, Exp = exp } },
|
||||
};
|
||||
}
|
||||
|
||||
private static BattleXpService NewService(BattleXpConfig cfg, params (int Level, int NecessaryExp)[] curve)
|
||||
{
|
||||
var rows = curve.Length > 0
|
||||
? curve
|
||||
: new[] { (1, 100), (2, 150), (3, 250) };
|
||||
return new BattleXpService(CurveRepo(rows), new FakeConfig(cfg), NullLogger<BattleXpService>.Instance);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Win_uses_XpPerWin_and_accumulates_exp()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 40, XpPerLoss = 5 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, classId: 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(40));
|
||||
Assert.That(r.TotalXp, Is.EqualTo(40));
|
||||
Assert.That(r.Level, Is.EqualTo(1));
|
||||
Assert.That(v.Classes.Single().Exp, Is.EqualTo(40));
|
||||
Assert.That(v.Classes.Single().Level, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_uses_XpPerLoss()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 40, XpPerLoss = 5 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, classId: 1, isWin: false, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(5));
|
||||
Assert.That(r.TotalXp, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Per_mode_override_wins_over_global()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig
|
||||
{
|
||||
XpPerWin = 40, XpPerLoss = 5,
|
||||
PracticeXpPerWin = 200, PracticeXpPerLoss = 20,
|
||||
});
|
||||
|
||||
var vw = NewViewerWithClass(1);
|
||||
var win = await svc.GrantAsync(vw, 1, isWin: true, BattleXpMode.Practice);
|
||||
Assert.That(win.GetXp, Is.EqualTo(200));
|
||||
|
||||
var vl = NewViewerWithClass(1);
|
||||
var loss = await svc.GrantAsync(vl, 1, isWin: false, BattleXpMode.Practice);
|
||||
Assert.That(loss.GetXp, Is.EqualTo(20));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Rank_mode_falls_back_to_global_when_override_null()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig
|
||||
{
|
||||
XpPerWin = 40, XpPerLoss = 5,
|
||||
PracticeXpPerWin = 200, // Rank has no override — must fall back.
|
||||
});
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(40));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Story_uses_StoryXpPerClear_when_set_ignoring_isWin()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig
|
||||
{
|
||||
XpPerWin = 40, XpPerLoss = 5,
|
||||
StoryXpPerClear = 300,
|
||||
});
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: false, BattleXpMode.Story);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(300));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Story_falls_back_to_XpPerWin_when_StoryXpPerClear_null()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 40, XpPerLoss = 5 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Story);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(40));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Single_grant_crossing_one_threshold_bumps_level_and_carries_overflow()
|
||||
{
|
||||
// Curve: L1 needs 100 to reach L2. Grant 130 → level 2, Exp 30 carry.
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 130 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.Level, Is.EqualTo(2));
|
||||
Assert.That(r.TotalXp, Is.EqualTo(30));
|
||||
Assert.That(v.Classes.Single().Level, Is.EqualTo(2));
|
||||
Assert.That(v.Classes.Single().Exp, Is.EqualTo(30));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Single_grant_crossing_multiple_thresholds_bumps_multiple_levels()
|
||||
{
|
||||
// Curve: L1=100, L2=150, L3=250.
|
||||
// Grant 500 → L1(100)→L2 (400 left) → L2(150)→L3 (250 left) → stop at maxLevel=3.
|
||||
// Final: Level=3, Exp=250.
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 500 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.Level, Is.EqualTo(3));
|
||||
Assert.That(r.TotalXp, Is.EqualTo(250));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Saturates_at_max_curve_level_with_excess_in_exp()
|
||||
{
|
||||
// Max curve level = 3. Grant enough to overshoot far.
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 10_000 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.Level, Is.EqualTo(3));
|
||||
// 10000 - 100 (L1→L2) - 150 (L2→L3) = 9750 sitting in Exp at L3.
|
||||
Assert.That(r.TotalXp, Is.EqualTo(9750));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Unknown_classId_returns_zero_and_does_not_mutate_viewer()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 40 });
|
||||
var v = NewViewerWithClass(classId: 1);
|
||||
|
||||
var r = await svc.GrantAsync(v, classId: 99, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(0));
|
||||
Assert.That(r.TotalXp, Is.EqualTo(0));
|
||||
Assert.That(r.Level, Is.EqualTo(1));
|
||||
Assert.That(v.Classes.Single().Exp, Is.EqualTo(0),
|
||||
"Class 1's row must not have been touched.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user