From a2048a4d54b38c9d0e4415388cd8d537898fa2d2 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 4 Jul 2026 13:23:34 -0400 Subject: [PATCH] feat(battle-xp): BattleXpGrantResult carries LeveledUp signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BattleXpGrantResult gains a LeveledUp bool set true iff at least one curve threshold was crossed during the grant (comparing pre-Level to post-Level after the level-up loop). Callers will gate class_level_up mission emits on this flag rather than caching pre-state themselves. No behavior change for existing callers — they read GetXp/TotalXp/Level today and never check LeveledUp yet. Tests cover: unchanged level → false, single threshold → true, multi-threshold within one grant → still true, unknown class guardrail → false. Co-Authored-By: Claude Opus 4.7 --- .../Services/BattleXp/BattleXpService.cs | 5 ++- .../Services/BattleXp/IBattleXpService.cs | 5 ++- .../Services/BattleXpServiceTests.cs | 42 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/SVSim.Database/Services/BattleXp/BattleXpService.cs b/SVSim.Database/Services/BattleXp/BattleXpService.cs index 59a3efad..a57f7a9b 100644 --- a/SVSim.Database/Services/BattleXp/BattleXpService.cs +++ b/SVSim.Database/Services/BattleXp/BattleXpService.cs @@ -30,7 +30,7 @@ public sealed class BattleXpService : IBattleXpService _log.LogWarning( "BattleXpService: viewer {ViewerId} has no ViewerClassData for classId {ClassId}; skipping grant.", viewer.Id, classId); - return new BattleXpGrantResult(0, 0, 1); + return new BattleXpGrantResult(0, 0, 1, LeveledUp: false); } int amount = ResolveAmount(mode, isWin); @@ -42,6 +42,7 @@ public sealed class BattleXpService : IBattleXpService var byLevel = curve.ToDictionary(e => e.Id, e => e.NecessaryExp); int maxLevel = curve.Count == 0 ? row.Level : curve.Max(e => e.Id); + int startingLevel = row.Level; while (row.Level < maxLevel && byLevel.TryGetValue(row.Level, out var needed) && row.Exp >= needed) @@ -50,7 +51,7 @@ public sealed class BattleXpService : IBattleXpService row.Level += 1; } - return new BattleXpGrantResult(amount, row.Exp, row.Level); + return new BattleXpGrantResult(amount, row.Exp, row.Level, LeveledUp: row.Level > startingLevel); } private int ResolveAmount(BattleXpMode mode, bool isWin) diff --git a/SVSim.Database/Services/BattleXp/IBattleXpService.cs b/SVSim.Database/Services/BattleXp/IBattleXpService.cs index e88f573d..06b839eb 100644 --- a/SVSim.Database/Services/BattleXp/IBattleXpService.cs +++ b/SVSim.Database/Services/BattleXp/IBattleXpService.cs @@ -6,8 +6,11 @@ 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). +/// is true iff at least one level threshold was +/// crossed during this grant — callers gate class_level_up mission emits +/// on this flag rather than caching pre-state themselves. /// -public sealed record BattleXpGrantResult(int GetXp, int TotalXp, int Level); +public sealed record BattleXpGrantResult(int GetXp, int TotalXp, int Level, bool LeveledUp); public interface IBattleXpService { diff --git a/SVSim.UnitTests/Services/BattleXpServiceTests.cs b/SVSim.UnitTests/Services/BattleXpServiceTests.cs index 3c8aaba6..17cabc58 100644 --- a/SVSim.UnitTests/Services/BattleXpServiceTests.cs +++ b/SVSim.UnitTests/Services/BattleXpServiceTests.cs @@ -185,7 +185,49 @@ public class BattleXpServiceTests Assert.That(r.GetXp, Is.EqualTo(0)); Assert.That(r.TotalXp, Is.EqualTo(0)); Assert.That(r.Level, Is.EqualTo(1)); + Assert.That(r.LeveledUp, Is.False); Assert.That(v.Classes.Single().Exp, Is.EqualTo(0), "Class 1's row must not have been touched."); } + + // ---- LeveledUp signal ---- + + [Test] + public async Task LeveledUp_is_false_when_grant_stays_within_same_level() + { + // L1 requires 100 to reach L2; grant only 40. + var svc = NewService(new BattleXpConfig { XpPerWin = 40 }); + var v = NewViewerWithClass(1); + + var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank); + + Assert.That(r.Level, Is.EqualTo(1)); + Assert.That(r.LeveledUp, Is.False); + } + + [Test] + public async Task LeveledUp_is_true_when_grant_crosses_a_threshold() + { + // L1=100 → grant 130 → L2 with 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.LeveledUp, Is.True); + } + + [Test] + public async Task LeveledUp_stays_true_across_multiple_bumps_in_one_grant() + { + // Grant crosses L1→L2→L3 in one go. + 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.LeveledUp, Is.True); + } }