feat(battle-xp): wire story first-clear /finish through IBattleXpService
- StoryConfig.ClassXpPerClear retired; equivalent knob moves to BattleXpConfig.StoryXpPerClear (falls back to XpPerWin when null). - StoryService.FinishAsync now grants class XP via IBattleXpService with BattleXpMode.Story on the firstClear && isPlayShape branch. Persists post-grant Level/Exp to Viewer.Classes. - Wire fields (get_class_experience, class_level) stringified as before. - StoryConfig kept as an empty [ConfigSection] for future story-specific knobs. StoryServiceTests updated to reflect the new default path (XpPerWin=100 crosses classexp.csv L1=50 → L2 with 50 carry). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
namespace SVSim.Database.Models.Config;
|
namespace SVSim.Database.Models.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Story-family placeholder config section. Class XP per clear moved to
|
||||||
|
/// <see cref="BattleXpConfig.StoryXpPerClear"/> (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.
|
||||||
|
/// </summary>
|
||||||
[ConfigSection("Story")]
|
[ConfigSection("Story")]
|
||||||
public class StoryConfig
|
public class StoryConfig
|
||||||
{
|
{
|
||||||
public int ClassXpPerClear { get; set; } = 200;
|
|
||||||
|
|
||||||
public static StoryConfig ShippedDefaults() => new();
|
public static StoryConfig ShippedDefaults() => new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ using SVSim.Database.Enums;
|
|||||||
using SVSim.Database.Models.Config;
|
using SVSim.Database.Models.Config;
|
||||||
using SVSim.Database.Repositories.Deck;
|
using SVSim.Database.Repositories.Deck;
|
||||||
using SVSim.Database.Repositories.BuildDeck;
|
using SVSim.Database.Repositories.BuildDeck;
|
||||||
|
using SVSim.Database.Repositories.Viewer;
|
||||||
using SVSim.Database.Services;
|
using SVSim.Database.Services;
|
||||||
|
using SVSim.Database.Services.BattleXp;
|
||||||
using SVSim.Database.Services.Inventory;
|
using SVSim.Database.Services.Inventory;
|
||||||
using SVSim.Database.Repositories.Story;
|
using SVSim.Database.Repositories.Story;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
@@ -25,6 +27,8 @@ public class StoryService : IStoryService
|
|||||||
private readonly IGameConfigService _configService;
|
private readonly IGameConfigService _configService;
|
||||||
private readonly IDeckRepository _deckRepository;
|
private readonly IDeckRepository _deckRepository;
|
||||||
private readonly IBuildDeckRepository _buildDecks;
|
private readonly IBuildDeckRepository _buildDecks;
|
||||||
|
private readonly IViewerRepository _viewers;
|
||||||
|
private readonly IBattleXpService _xp;
|
||||||
private readonly ILogger<StoryService> _logger;
|
private readonly ILogger<StoryService> _logger;
|
||||||
|
|
||||||
public StoryService(
|
public StoryService(
|
||||||
@@ -35,6 +39,8 @@ public class StoryService : IStoryService
|
|||||||
IGameConfigService configService,
|
IGameConfigService configService,
|
||||||
IDeckRepository deckRepository,
|
IDeckRepository deckRepository,
|
||||||
IBuildDeckRepository buildDecks,
|
IBuildDeckRepository buildDecks,
|
||||||
|
IViewerRepository viewers,
|
||||||
|
IBattleXpService xp,
|
||||||
ILogger<StoryService> logger)
|
ILogger<StoryService> logger)
|
||||||
{
|
{
|
||||||
_master = master;
|
_master = master;
|
||||||
@@ -44,6 +50,8 @@ public class StoryService : IStoryService
|
|||||||
_configService = configService;
|
_configService = configService;
|
||||||
_deckRepository = deckRepository;
|
_deckRepository = deckRepository;
|
||||||
_buildDecks = buildDecks;
|
_buildDecks = buildDecks;
|
||||||
|
_viewers = viewers;
|
||||||
|
_xp = xp;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,12 +585,15 @@ public class StoryService : IStoryService
|
|||||||
{
|
{
|
||||||
// XP grant requires a class_id (only sent on play-shape). No-battle chapters
|
// 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.
|
// have no class context — prod returns get_class_experience=0 for them.
|
||||||
var xp = _configService.Get<StoryConfig>().ClassXpPerClear;
|
var xpViewer = await _viewers.LoadForBattleXpGrantAsync(viewerId);
|
||||||
resp.GetClassExperience = xp.ToString();
|
if (xpViewer is not null && req.ClassId.HasValue)
|
||||||
// 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.
|
var xp = await _xp.GrantAsync(xpViewer, req.ClassId.Value, isWin: true, BattleXpMode.Story);
|
||||||
resp.ClassExperience = 0;
|
await _db.SaveChangesAsync();
|
||||||
resp.ClassLevel = "0";
|
resp.GetClassExperience = xp.GetXp.ToString();
|
||||||
|
resp.ClassExperience = xp.TotalXp;
|
||||||
|
resp.ClassLevel = xp.Level.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ public class StoryServiceTests
|
|||||||
configService: StoryServiceTestHelpers.NewConfigService(),
|
configService: StoryServiceTestHelpers.NewConfigService(),
|
||||||
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
||||||
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
|
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
|
||||||
|
viewers: new Mock<SVSim.Database.Repositories.Viewer.IViewerRepository>().Object,
|
||||||
|
xp: new Mock<SVSim.Database.Services.BattleXp.IBattleXpService>().Object,
|
||||||
logger: NullLogger<StoryService>.Instance);
|
logger: NullLogger<StoryService>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +83,8 @@ public class StoryServiceTests
|
|||||||
configService: StoryServiceTestHelpers.NewConfigService(),
|
configService: StoryServiceTestHelpers.NewConfigService(),
|
||||||
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
||||||
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
|
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
|
||||||
|
viewers: scope.ServiceProvider.GetRequiredService<SVSim.Database.Repositories.Viewer.IViewerRepository>(),
|
||||||
|
xp: scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
|
||||||
logger: NullLogger<StoryService>.Instance);
|
logger: NullLogger<StoryService>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +418,8 @@ public class StoryServiceTests
|
|||||||
configService: StoryServiceTestHelpers.NewConfigService(),
|
configService: StoryServiceTestHelpers.NewConfigService(),
|
||||||
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
||||||
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
|
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
|
||||||
|
viewers: new Mock<SVSim.Database.Repositories.Viewer.IViewerRepository>().Object,
|
||||||
|
xp: new Mock<SVSim.Database.Services.BattleXp.IBattleXpService>().Object,
|
||||||
logger: NullLogger<StoryService>.Instance);
|
logger: NullLogger<StoryService>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,14 +630,26 @@ public class StoryServiceTests
|
|||||||
// Viewer started at RedEther=0; grant of 100 → post-state total = 100.
|
// Viewer started at RedEther=0; grant of 100 → post-state total = 100.
|
||||||
Assert.That(resp.RewardList, Has.Count.EqualTo(1));
|
Assert.That(resp.RewardList, Has.Count.EqualTo(1));
|
||||||
Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100"));
|
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);
|
_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();
|
using var verifyScope = factory.Services.CreateScope();
|
||||||
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
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));
|
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<SVSim.Database.Services.IGameConfigService>();
|
var mock = new Mock<SVSim.Database.Services.IGameConfigService>();
|
||||||
mock.Setup(s => s.Get<SVSim.Database.Models.Config.StoryConfig>())
|
mock.Setup(s => s.Get<SVSim.Database.Models.Config.StoryConfig>())
|
||||||
.Returns(new SVSim.Database.Models.Config.StoryConfig { ClassXpPerClear = 200 });
|
.Returns(new SVSim.Database.Models.Config.StoryConfig());
|
||||||
return mock.Object;
|
return mock.Object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user