refactor(story): route FinishAsync rewards through InventoryService

Replace RewardGrantService with IInventoryService tx. Per-reward GrantAsync
calls inside try/catch preserve the NotSupportedException skip; CommitAsync
returns result.RewardList (post-state totals) and accumulated delta list feeds
story_reward_list. Update StoryServiceTests to inject IInventoryService.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 16:42:38 -04:00
parent a310697830
commit 7c4bc2966f
2 changed files with 42 additions and 48 deletions

View File

@@ -8,6 +8,7 @@ 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.Services; using SVSim.Database.Services;
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;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
@@ -19,7 +20,7 @@ public class StoryService : IStoryService
{ {
private readonly IStoryMasterRepository _master; private readonly IStoryMasterRepository _master;
private readonly IViewerStoryProgressRepository _viewer; private readonly IViewerStoryProgressRepository _viewer;
private readonly RewardGrantService _rewards; private readonly IInventoryService _inv;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly IGameConfigService _configService; private readonly IGameConfigService _configService;
private readonly IDeckRepository _deckRepository; private readonly IDeckRepository _deckRepository;
@@ -29,7 +30,7 @@ public class StoryService : IStoryService
public StoryService( public StoryService(
IStoryMasterRepository master, IStoryMasterRepository master,
IViewerStoryProgressRepository viewer, IViewerStoryProgressRepository viewer,
RewardGrantService rewards, IInventoryService inv,
SVSimDbContext db, SVSimDbContext db,
IGameConfigService configService, IGameConfigService configService,
IDeckRepository deckRepository, IDeckRepository deckRepository,
@@ -38,7 +39,7 @@ public class StoryService : IStoryService
{ {
_master = master; _master = master;
_viewer = viewer; _viewer = viewer;
_rewards = rewards; _inv = inv;
_db = db; _db = db;
_configService = configService; _configService = configService;
_deckRepository = deckRepository; _deckRepository = deckRepository;
@@ -519,28 +520,26 @@ public class StoryService : IStoryService
if (firstClear && chapter.Rewards.Count > 0) if (firstClear && chapter.Rewards.Count > 0)
{ {
// Load viewer with all collections RewardGrantService might mutate. Split-query // Open inventory tx — skip the load entirely when no rewards (narrative-only
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query"). Skip the // chapters where the only side effect is the progress upsert).
// load entirely when the chapter has no rewards — common for narrative-only await using var tx = await _inv.BeginAsync(viewerId);
// chapters (limited/event story) where the only side effect is the progress upsert.
var viewer = await _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// reward_list and story_reward_list have DIFFERENT semantics for reward_num:
// - reward_list: post-state totals. Client (PlayerStaticData
// .UpdateHaveUserGoodsNum) direct-assigns to in-memory
// balances (e.g. UserRupyCount = num).
// - story_reward_list: deltas. Client (ResultAnimationAgent
// .HandleStoryAndMissionRewards) feeds each entry to
// AddReward(item) which draws a "+N received" popup line.
// GrantAsync may return 1+N entries (Card grants cascade into cosmetics). All
// post-state entries go into reward_list via result.RewardList; story_reward_list
// only gets the top-level mission row's delta (cascade cosmetics have no row).
var storyRewardDeltas = new List<RewardGrant>();
foreach (var r in chapter.Rewards) foreach (var r in chapter.Rewards)
{ {
IReadOnlyList<GrantedReward> granted;
try try
{ {
granted = await _rewards.ApplyAsync( await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
} }
catch (NotSupportedException ex) catch (NotSupportedException ex)
{ {
@@ -549,27 +548,8 @@ public class StoryService : IStoryService
r.RewardType, r.RewardDetailId, r.RewardNumber, req.StoryId); r.RewardType, r.RewardDetailId, r.RewardNumber, req.StoryId);
continue; continue;
} }
// delta for story_reward_list: raw catalog amounts (not post-state)
// reward_list and story_reward_list have DIFFERENT semantics for reward_num: storyRewardDeltas.Add(new RewardGrant
// - reward_list: post-state totals. Client (PlayerStaticData
// .UpdateHaveUserGoodsNum) direct-assigns to in-memory
// balances (e.g. UserRupyCount = num).
// - story_reward_list: deltas. Client (ResultAnimationAgent
// .HandleStoryAndMissionRewards) feeds each entry to
// AddReward(item) which draws a "+N received" popup line.
// ApplyAsync may return 1+N entries (Card grants cascade into cosmetics). All
// post-state entries go into reward_list; story_reward_list only gets the
// top-level mission row's delta (cascade cosmetics have no corresponding row).
foreach (var g in granted)
{
resp.RewardList.Add(new RewardGrant
{
RewardType = g.RewardType.ToString(),
RewardId = g.RewardId.ToString(),
RewardNum = g.RewardNum.ToString(),
});
}
resp.StoryRewardList.Add(new RewardGrant
{ {
RewardType = ((int)r.RewardType).ToString(), RewardType = ((int)r.RewardType).ToString(),
RewardId = r.RewardDetailId.ToString(), RewardId = r.RewardDetailId.ToString(),
@@ -577,7 +557,20 @@ public class StoryService : IStoryService
}); });
} }
await _db.SaveChangesAsync(); var result = await tx.CommitAsync();
// reward_list = post-state totals from tx (includes cosmetic cascade entries)
foreach (var g in result.RewardList)
{
resp.RewardList.Add(new RewardGrant
{
RewardType = g.RewardType.ToString(),
RewardId = g.RewardId.ToString(),
RewardNum = g.RewardNum.ToString(),
});
}
// story_reward_list = deltas accumulated above
resp.StoryRewardList.AddRange(storyRewardDeltas);
} }
if (firstClear && isPlayShape) if (firstClear && isPlayShape)

View File

@@ -8,6 +8,7 @@ using SVSim.Database.Entities.Story;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.Story; using SVSim.Database.Repositories.Story;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Story; using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure; using SVSim.UnitTests.Infrastructure;
@@ -26,12 +27,12 @@ public class StoryServiceTests
{ {
_master = new Mock<IStoryMasterRepository>(); _master = new Mock<IStoryMasterRepository>();
_viewer = new Mock<IViewerStoryProgressRepository>(); _viewer = new Mock<IViewerStoryProgressRepository>();
// Non-reward tests never exercise the DB/reward path; use a stub InMemory context. // Non-reward tests never exercise the DB/reward path; use a stub InMemory context + null inv.
var db = StoryServiceTestHelpers.NewInMemoryDb(nameof(SetUp)); var db = StoryServiceTestHelpers.NewInMemoryDb(nameof(SetUp));
var rewards = new RewardGrantService(db, NullLogger<RewardGrantService>.Instance); var inv = new Mock<IInventoryService>().Object;
_service = new StoryService( _service = new StoryService(
_master.Object, _viewer.Object, _master.Object, _viewer.Object,
rewards: rewards, inv: inv,
db: db, db: db,
configService: StoryServiceTestHelpers.NewConfigService(), configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object, deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
@@ -64,12 +65,12 @@ public class StoryServiceTests
scope = factory.Services.CreateScope(); scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>(); var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var rewards = scope.ServiceProvider.GetRequiredService<RewardGrantService>(); var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
return new StoryService( return new StoryService(
_master.Object, _master.Object,
_viewer.Object, _viewer.Object,
rewards: rewards, inv: inv,
db: db, db: db,
configService: StoryServiceTestHelpers.NewConfigService(), configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object, deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
@@ -402,7 +403,7 @@ public class StoryServiceTests
db.SaveChanges(); db.SaveChanges();
return new StoryService( return new StoryService(
_master.Object, _viewer.Object, _master.Object, _viewer.Object,
rewards: new RewardGrantService(db, NullLogger<RewardGrantService>.Instance), inv: new Mock<IInventoryService>().Object,
db: db, db: db,
configService: StoryServiceTestHelpers.NewConfigService(), configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object, deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,