diff --git a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs index 2a07fcc..d2ad0c3 100644 --- a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.BattlePass; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; @@ -22,23 +23,20 @@ public sealed class BattlePassService : IBattlePassService private readonly IViewerBattlePassRepository _viewerBp; private readonly TimeProvider _time; private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; - private readonly ICurrencySpendService _spend; + private readonly IInventoryService _inv; public BattlePassService( IBattlePassRepository bp, IViewerBattlePassRepository viewerBp, TimeProvider time, SVSimDbContext db, - RewardGrantService rewards, - ICurrencySpendService spend) + IInventoryService inv) { _bp = bp; _viewerBp = viewerBp; _time = time; _db = db; - _rewards = rewards; - _spend = spend; + _inv = inv; } public async Task?> GetLevelCurveAsync(CancellationToken ct) @@ -156,26 +154,22 @@ public sealed class BattlePassService : IBattlePassService if (productId != season.Id * 1000) return new BattlePassBuyOutcome(0, Array.Empty(), Array.Empty()); - 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() // per memory project_ef_split_query - .FirstOrDefaultAsync(v => v.Id == viewerId, ct); - if (viewer is null) + // Guard: viewer must exist (BeginAsync throws InventoryViewerNotFoundException otherwise). + var viewerExists = await _db.Viewers.AnyAsync(v => v.Id == viewerId, ct); + if (!viewerExists) return new BattlePassBuyOutcome(0, Array.Empty(), Array.Empty()); var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct); if (progress.IsPremium) return new BattlePassBuyOutcome(23, Array.Empty(), Array.Empty()); - var spendResult = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, season.PriceCrystal, ct); + // Open inventory tx — loads viewer + opens DB tx. + await using var tx = await _inv.BeginAsync(viewerId, ct); + + var spendResult = await tx.TrySpendAsync(SpendCurrency.Crystal, season.PriceCrystal, ct); if (!spendResult.Success) return new BattlePassBuyOutcome(22, Array.Empty(), Array.Empty()); - // BeginTransactionAsync is a no-op on the SQLite in-memory test DB but is safe to call. - await using var tx = await _db.Database.BeginTransactionAsync(ct); - progress.IsPremium = true; // Retroactive grants: every premium reward at level <= current_level not already claimed. @@ -186,32 +180,21 @@ public sealed class BattlePassService : IBattlePassService var curve = await _bp.GetLevelCurveAsync(ct); int currentLevel = ComputeLevel(curve, progress.CurrentPoint); - // achieved = delta list (the original reward spec amounts — what was just granted). - // postState = post-state totals from RewardGrantService (what goes in reward_list). - var achieved = new List(); - var postState = new List(); foreach (var r in rewards.Where(r => r.Track == BattlePassTrack.Premium && r.Level <= currentLevel)) { if (claimSet.Contains((r.Track, r.Level))) continue; _viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now); - var granted = await _rewards.ApplyAsync( - viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct); - // achieved_info uses the original reward spec (delta), not post-state. - achieved.Add(new GrantedReward(r.RewardType, r.RewardDetailId, r.RewardNumber)); - postState.AddRange(granted); + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct); } - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); + // CommitAsync handles DB save + currency-collision rule. Crystal spend is the first + // op, any grants override the post-state. result.RewardList carries the final + // post-state including the deducted crystal balance. result.Deltas carries the raw + // grant amounts for achieved_info (no spend entry in Deltas, only GrantOps). + var result = await tx.CommitAsync(ct); + await _db.SaveChangesAsync(ct); // flush claim rows added via _viewerBp.AddClaim - // Post-state reward_list must always include the crystal balance after the deduction. - // Unconditionally overwrite: remove any crystal entry ApplyAsync may have added, then - // append the post-deduction total so the client gets the correct final balance. - postState.RemoveAll(r => r.RewardType == (int)UserGoodsType.Crystal); - postState.Add(new GrantedReward( - (int)UserGoodsType.Crystal, 0, (int)spendResult.PostStateTotal)); - - return new BattlePassBuyOutcome(1, achieved, postState); + return new BattlePassBuyOutcome(1, result.Deltas, result.RewardList); } public async Task AddPointsAsync( @@ -225,14 +208,6 @@ public sealed class BattlePassService : IBattlePassService Array.Empty()); } - 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() - .FirstOrDefaultAsync(v => v.Id == viewerId, ct) - ?? throw new InvalidOperationException($"viewer {viewerId} not found"); - var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct); int beforePoint = progress.CurrentPoint; @@ -248,13 +223,15 @@ public sealed class BattlePassService : IBattlePassService int afterLevel = ComputeLevel(curve, progress.CurrentPoint); - var newlyClaimed = new List(); + IReadOnlyList newlyClaimed = Array.Empty(); if (afterLevel > beforeLevel) { var rewards = await _bp.GetSeasonRewardsAsync(season.Id, ct); var claims = await _viewerBp.GetClaimsAsync(viewerId, season.Id, ct); var claimSet = claims.Select(c => (c.Track, c.Level)).ToHashSet(); + await using var tx = await _inv.BeginAsync(viewerId, ct); + for (int level = beforeLevel + 1; level <= afterLevel; level++) { foreach (var r in rewards.Where(r => r.Level == level)) @@ -262,11 +239,12 @@ public sealed class BattlePassService : IBattlePassService if (r.Track == BattlePassTrack.Premium && !progress.IsPremium) continue; if (claimSet.Contains((r.Track, r.Level))) continue; _viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now); - var granted = await _rewards.ApplyAsync( - viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct); - newlyClaimed.AddRange(granted); + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct); } } + + var result = await tx.CommitAsync(ct); + newlyClaimed = result.Deltas; } await _db.SaveChangesAsync(ct);