From 81771bb0a05d0164a1b9b1c47dcd6d3e6edaa45a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 4 Jul 2026 08:51:43 -0400 Subject: [PATCH] feat(battle-xp): IBattleXpService with per-mode overrides and auto level-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Services/BattleXp/BattleXpService.cs | 79 ++++++++ .../Services/BattleXp/IBattleXpService.cs | 36 ++++ SVSim.EmulatedEntrypoint/Program.cs | 2 + .../Models/GameConfigurationJsonbTests.cs | 6 +- .../Services/BattleXpServiceTests.cs | 191 ++++++++++++++++++ 5 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 SVSim.Database/Services/BattleXp/BattleXpService.cs create mode 100644 SVSim.Database/Services/BattleXp/IBattleXpService.cs create mode 100644 SVSim.UnitTests/Services/BattleXpServiceTests.cs diff --git a/SVSim.Database/Services/BattleXp/BattleXpService.cs b/SVSim.Database/Services/BattleXp/BattleXpService.cs new file mode 100644 index 00000000..59a3efad --- /dev/null +++ b/SVSim.Database/Services/BattleXp/BattleXpService.cs @@ -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 _log; + + // Curve is immutable per boot; cache the first fetch. + private List? _cachedCurve; + + public BattleXpService(IGlobalsRepository globals, IGameConfigService config, ILogger log) + { + _globals = globals; + _config = config; + _log = log; + } + + public async Task 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(); + + 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); + } +} diff --git a/SVSim.Database/Services/BattleXp/IBattleXpService.cs b/SVSim.Database/Services/BattleXp/IBattleXpService.cs new file mode 100644 index 00000000..e88f573d --- /dev/null +++ b/SVSim.Database/Services/BattleXp/IBattleXpService.cs @@ -0,0 +1,36 @@ +using SVSim.Database.Models; + +namespace SVSim.Database.Services.BattleXp; + +/// +/// Amounts returned to callers after a class-XP grant. and +/// are POST-grant, POST-level-up (matching the wire shape's +/// class_experience + class_level post-state semantics). +/// +public sealed record BattleXpGrantResult(int GetXp, int TotalXp, int Level); + +public interface IBattleXpService +{ + /// + /// Grants class XP for a battle finish. Caller supplies a viewer loaded via + /// + /// (or equivalent, with .Include(v => v.Classes).ThenInclude(c => c.Class)). + /// Caller SaveChangesAsync after this returns. + /// + /// Amount resolution: mode-specific config override if non-null, else global + /// XpPerWin/XpPerLoss. Story ignores (always + /// treated as a clear). + /// + /// + /// Level-up: loops on ClassExpEntry curve; row.Exp stores level-relative + /// XP and carries overflow after each level-up. Saturates at curve max level (excess + /// piles in Exp). + /// + /// + /// Guardrail: viewer has no row for + /// → returns (0, 0, 1), no mutation, logs Warning. + /// + /// + Task GrantAsync( + Viewer viewer, int classId, bool isWin, BattleXpMode mode, CancellationToken ct = default); +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index e3e2355d..1d5f10b7 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -99,6 +99,8 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped 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(byName["ResourceConfig"].ValueJson)!; diff --git a/SVSim.UnitTests/Services/BattleXpServiceTests.cs b/SVSim.UnitTests/Services/BattleXpServiceTests.cs new file mode 100644 index 00000000..3c8aaba6 --- /dev/null +++ b/SVSim.UnitTests/Services/BattleXpServiceTests.cs @@ -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() where T : class, new() => (T)(object)_cfg; + } + + private static IGlobalsRepository CurveRepo(params (int Level, int NecessaryExp)[] rows) + { + var mock = new Mock(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.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."); + } +}