diff --git a/SVSim.Database/Models/Config/StoryConfig.cs b/SVSim.Database/Models/Config/StoryConfig.cs
index 88c7b2f4..c66b95da 100644
--- a/SVSim.Database/Models/Config/StoryConfig.cs
+++ b/SVSim.Database/Models/Config/StoryConfig.cs
@@ -1,9 +1,13 @@
namespace SVSim.Database.Models.Config;
+///
+/// Story-family placeholder config section. Class XP per clear moved to
+/// (BattleXpMode.Story) as part of the
+/// unified per-mode XP surface. Kept as an empty section so future story-specific
+/// knobs (dialogue speed, auto-skip, etc.) have a home.
+///
[ConfigSection("Story")]
public class StoryConfig
{
- public int ClassXpPerClear { get; set; } = 200;
-
public static StoryConfig ShippedDefaults() => new();
}
diff --git a/SVSim.EmulatedEntrypoint/Services/StoryService.cs b/SVSim.EmulatedEntrypoint/Services/StoryService.cs
index 18826b0f..31159a07 100644
--- a/SVSim.EmulatedEntrypoint/Services/StoryService.cs
+++ b/SVSim.EmulatedEntrypoint/Services/StoryService.cs
@@ -7,7 +7,9 @@ using SVSim.Database.Enums;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.BuildDeck;
+using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
+using SVSim.Database.Services.BattleXp;
using SVSim.Database.Services.Inventory;
using SVSim.Database.Repositories.Story;
using SVSim.EmulatedEntrypoint.Models.Dtos;
@@ -25,6 +27,8 @@ public class StoryService : IStoryService
private readonly IGameConfigService _configService;
private readonly IDeckRepository _deckRepository;
private readonly IBuildDeckRepository _buildDecks;
+ private readonly IViewerRepository _viewers;
+ private readonly IBattleXpService _xp;
private readonly ILogger _logger;
public StoryService(
@@ -35,6 +39,8 @@ public class StoryService : IStoryService
IGameConfigService configService,
IDeckRepository deckRepository,
IBuildDeckRepository buildDecks,
+ IViewerRepository viewers,
+ IBattleXpService xp,
ILogger logger)
{
_master = master;
@@ -44,6 +50,8 @@ public class StoryService : IStoryService
_configService = configService;
_deckRepository = deckRepository;
_buildDecks = buildDecks;
+ _viewers = viewers;
+ _xp = xp;
_logger = logger;
}
@@ -577,12 +585,15 @@ public class StoryService : IStoryService
{
// XP grant requires a class_id (only sent on play-shape). No-battle chapters
// have no class context — prod returns get_class_experience=0 for them.
- var xp = _configService.Get().ClassXpPerClear;
- resp.GetClassExperience = xp.ToString();
- // class_experience / class_level updates would consult the viewer's per-class XP
- // table — placeholder zeros; wire to viewer.Classes[class_id] when that path exists.
- resp.ClassExperience = 0;
- resp.ClassLevel = "0";
+ var xpViewer = await _viewers.LoadForBattleXpGrantAsync(viewerId);
+ if (xpViewer is not null && req.ClassId.HasValue)
+ {
+ var xp = await _xp.GrantAsync(xpViewer, req.ClassId.Value, isWin: true, BattleXpMode.Story);
+ await _db.SaveChangesAsync();
+ resp.GetClassExperience = xp.GetXp.ToString();
+ resp.ClassExperience = xp.TotalXp;
+ resp.ClassLevel = xp.Level.ToString();
+ }
}
}
else
diff --git a/SVSim.UnitTests/Story/StoryServiceTests.cs b/SVSim.UnitTests/Story/StoryServiceTests.cs
index 1d55d51a..8cab7cbb 100644
--- a/SVSim.UnitTests/Story/StoryServiceTests.cs
+++ b/SVSim.UnitTests/Story/StoryServiceTests.cs
@@ -43,6 +43,8 @@ public class StoryServiceTests
configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock().Object,
buildDecks: new Mock().Object,
+ viewers: new Mock().Object,
+ xp: new Mock().Object,
logger: NullLogger.Instance);
}
@@ -81,6 +83,8 @@ public class StoryServiceTests
configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock().Object,
buildDecks: new Mock().Object,
+ viewers: scope.ServiceProvider.GetRequiredService(),
+ xp: scope.ServiceProvider.GetRequiredService(),
logger: NullLogger.Instance);
}
@@ -414,6 +418,8 @@ public class StoryServiceTests
configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock().Object,
buildDecks: new Mock().Object,
+ viewers: new Mock().Object,
+ xp: new Mock().Object,
logger: NullLogger.Instance);
}
@@ -624,14 +630,26 @@ public class StoryServiceTests
// Viewer started at RedEther=0; grant of 100 → post-state total = 100.
Assert.That(resp.RewardList, Has.Count.EqualTo(1));
Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100"));
- Assert.That(resp.GetClassExperience, Is.EqualTo("200"));
+ // Story XP resolves via DI-registered IBattleXpService → real IGameConfigService
+ // (the local NewConfigService mock is passed to StoryService but the XP service
+ // pulls its own config from DI). BattleXpConfig.ShippedDefaults(): XpPerWin=100,
+ // StoryXpPerClear=null → falls back to XpPerWin=100. Curve L1=50, so 100 XP
+ // → L2 with 50 carry.
+ Assert.That(resp.GetClassExperience, Is.EqualTo("100"));
+ Assert.That(resp.ClassExperience, Is.EqualTo(50));
+ Assert.That(resp.ClassLevel, Is.EqualTo("2"));
_viewer.Verify(v => v.UpsertProgressAsync(viewerId, 100, true, null), Times.Once);
- // Confirm currency persisted: fetch fresh viewer from a new scope.
+ // Confirm currency + class XP persisted: fetch fresh viewer from a new scope.
using var verifyScope = factory.Services.CreateScope();
var db2 = verifyScope.ServiceProvider.GetRequiredService();
- var freshViewer = await db2.Viewers.FirstAsync(v => v.Id == viewerId);
+ var freshViewer = await db2.Viewers
+ .Include(v => v.Classes).ThenInclude(c => c.Class)
+ .FirstAsync(v => v.Id == viewerId);
Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(100UL));
+ var cls2 = freshViewer.Classes.Single(c => c.Class.Id == 2);
+ Assert.That(cls2.Level, Is.EqualTo(2));
+ Assert.That(cls2.Exp, Is.EqualTo(50));
}
}
@@ -828,7 +846,7 @@ internal static class StoryServiceTestHelpers
{
var mock = new Mock();
mock.Setup(s => s.Get())
- .Returns(new SVSim.Database.Models.Config.StoryConfig { ClassXpPerClear = 200 });
+ .Returns(new SVSim.Database.Models.Config.StoryConfig());
return mock.Object;
}