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:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user