diff --git a/SVSim.EmulatedEntrypoint/Services/StoryService.cs b/SVSim.EmulatedEntrypoint/Services/StoryService.cs index b6b62f7..d2ff5df 100644 --- a/SVSim.EmulatedEntrypoint/Services/StoryService.cs +++ b/SVSim.EmulatedEntrypoint/Services/StoryService.cs @@ -8,6 +8,7 @@ using SVSim.Database.Models.Config; using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.BuildDeck; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.Database.Repositories.Story; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Common; @@ -19,7 +20,7 @@ public class StoryService : IStoryService { private readonly IStoryMasterRepository _master; private readonly IViewerStoryProgressRepository _viewer; - private readonly RewardGrantService _rewards; + private readonly IInventoryService _inv; private readonly SVSimDbContext _db; private readonly IGameConfigService _configService; private readonly IDeckRepository _deckRepository; @@ -29,7 +30,7 @@ public class StoryService : IStoryService public StoryService( IStoryMasterRepository master, IViewerStoryProgressRepository viewer, - RewardGrantService rewards, + IInventoryService inv, SVSimDbContext db, IGameConfigService configService, IDeckRepository deckRepository, @@ -38,7 +39,7 @@ public class StoryService : IStoryService { _master = master; _viewer = viewer; - _rewards = rewards; + _inv = inv; _db = db; _configService = configService; _deckRepository = deckRepository; @@ -519,28 +520,26 @@ public class StoryService : IStoryService if (firstClear && chapter.Rewards.Count > 0) { - // Load viewer with all collections RewardGrantService might mutate. Split-query - // to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query"). Skip the - // load entirely when the chapter has no rewards — common for narrative-only - // 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); + // Open inventory tx — skip the load entirely when no rewards (narrative-only + // chapters where the only side effect is the progress upsert). + await using var tx = await _inv.BeginAsync(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(); foreach (var r in chapter.Rewards) { - IReadOnlyList granted; try { - granted = await _rewards.ApplyAsync( - viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); } catch (NotSupportedException ex) { @@ -549,27 +548,8 @@ public class StoryService : IStoryService r.RewardType, r.RewardDetailId, r.RewardNumber, req.StoryId); continue; } - - // 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. - // 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 + // delta for story_reward_list: raw catalog amounts (not post-state) + storyRewardDeltas.Add(new RewardGrant { RewardType = ((int)r.RewardType).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) diff --git a/SVSim.UnitTests/Story/StoryServiceTests.cs b/SVSim.UnitTests/Story/StoryServiceTests.cs index 6590ae3..7e1e20a 100644 --- a/SVSim.UnitTests/Story/StoryServiceTests.cs +++ b/SVSim.UnitTests/Story/StoryServiceTests.cs @@ -8,6 +8,7 @@ using SVSim.Database.Entities.Story; using SVSim.Database.Models; using SVSim.Database.Repositories.Story; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos.Story; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -26,12 +27,12 @@ public class StoryServiceTests { _master = new Mock(); _viewer = new Mock(); - // 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 rewards = new RewardGrantService(db, NullLogger.Instance); + var inv = new Mock().Object; _service = new StoryService( _master.Object, _viewer.Object, - rewards: rewards, + inv: inv, db: db, configService: StoryServiceTestHelpers.NewConfigService(), deckRepository: new Mock().Object, @@ -64,12 +65,12 @@ public class StoryServiceTests scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var rewards = scope.ServiceProvider.GetRequiredService(); + var inv = scope.ServiceProvider.GetRequiredService(); return new StoryService( _master.Object, _viewer.Object, - rewards: rewards, + inv: inv, db: db, configService: StoryServiceTestHelpers.NewConfigService(), deckRepository: new Mock().Object, @@ -402,7 +403,7 @@ public class StoryServiceTests db.SaveChanges(); return new StoryService( _master.Object, _viewer.Object, - rewards: new RewardGrantService(db, NullLogger.Instance), + inv: new Mock().Object, db: db, configService: StoryServiceTestHelpers.NewConfigService(), deckRepository: new Mock().Object,