diff --git a/SVSim.Database/Models/Config/BattleXpConfig.cs b/SVSim.Database/Models/Config/BattleXpConfig.cs
new file mode 100644
index 00000000..0a83c413
--- /dev/null
+++ b/SVSim.Database/Models/Config/BattleXpConfig.cs
@@ -0,0 +1,28 @@
+namespace SVSim.Database.Models.Config;
+
+///
+/// Class XP awarded on battle finish. /
+/// are the global defaults; each per-mode nullable slot overrides them for that mode
+/// when set. Story is clear-only (no loss variant) —
+/// falls back to when null.
+///
+[ConfigSection("BattleXp")]
+public class BattleXpConfig
+{
+ public int XpPerWin { get; set; } = 100;
+ public int XpPerLoss { get; set; } = 25;
+
+ public int? PracticeXpPerWin { get; set; }
+ public int? PracticeXpPerLoss { get; set; }
+ public int? RankXpPerWin { get; set; }
+ public int? RankXpPerLoss { get; set; }
+ public int? FreeXpPerWin { get; set; }
+ public int? FreeXpPerLoss { get; set; }
+ public int? ArenaTwoPickXpPerWin { get; set; }
+ public int? ArenaTwoPickXpPerLoss { get; set; }
+ public int? ColosseumXpPerWin { get; set; }
+ public int? ColosseumXpPerLoss { get; set; }
+ public int? StoryXpPerClear { get; set; }
+
+ public static BattleXpConfig ShippedDefaults() => new();
+}
diff --git a/SVSim.Database/Repositories/Viewer/IViewerRepository.cs b/SVSim.Database/Repositories/Viewer/IViewerRepository.cs
index da2e98ca..ea93d8ad 100644
--- a/SVSim.Database/Repositories/Viewer/IViewerRepository.cs
+++ b/SVSim.Database/Repositories/Viewer/IViewerRepository.cs
@@ -51,6 +51,14 @@ public interface IViewerRepository
///
Task LoadForMatchContextAsync(long viewerId);
+ ///
+ /// Focused load for class-XP grants: viewer with owned Classes collection and
+ /// each ViewerClassData.Class nav ref included. Tracked (not AsNoTracking) so
+ /// the caller can mutate Exp/Level and SaveChangesAsync. Returns
+ /// null if the viewer does not exist.
+ ///
+ Task LoadForBattleXpGrantAsync(long viewerId, CancellationToken ct = default);
+
/// Sets Viewer.GuildId to . No-op if the viewer does not exist.
Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default);
diff --git a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs
index 9b7b4e24..ed06edef 100644
--- a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs
+++ b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs
@@ -273,6 +273,14 @@ public class ViewerRepository : IViewerRepository
.FirstOrDefaultAsync(v => v.Id == viewerId);
}
+ public Task LoadForBattleXpGrantAsync(long viewerId, CancellationToken ct = default)
+ {
+ return _dbContext.Set()
+ .Include(v => v.Classes)
+ .ThenInclude(c => c.Class)
+ .FirstOrDefaultAsync(v => v.Id == viewerId, ct);
+ }
+
public async Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default)
{
var viewer = await _dbContext.Set()
diff --git a/SVSim.Database/Services/BattleXp/BattleXpMode.cs b/SVSim.Database/Services/BattleXp/BattleXpMode.cs
new file mode 100644
index 00000000..5ab2c06d
--- /dev/null
+++ b/SVSim.Database/Services/BattleXp/BattleXpMode.cs
@@ -0,0 +1,11 @@
+namespace SVSim.Database.Services.BattleXp;
+
+public enum BattleXpMode
+{
+ Practice,
+ Rank,
+ Free,
+ ArenaTwoPick,
+ Colosseum,
+ Story,
+}