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);
+ }
}