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