refactor(battle-pass): route BuyPremiumAsync and AddPointsAsync through InventoryService

Replace RewardGrantService + ICurrencySpendService with IInventoryService tx.
CommitAsync's currency-collision rule replaces the manual Crystal RemoveAll+re-append
scrub in BuyPremiumAsync. AddPointsAsync uses result.Deltas for NewlyClaimed to
preserve per-track visibility (two Rupy grants stay two entries).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 16:46:13 -04:00
parent 7c4bc2966f
commit 26bc4fe2ab

View File

@@ -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<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
@@ -156,26 +154,22 @@ public sealed class BattlePassService : IBattlePassService
if (productId != season.Id * 1000)
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
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<GrantedReward>(), Array.Empty<GrantedReward>());
var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct);
if (progress.IsPremium)
return new BattlePassBuyOutcome(23, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
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<GrantedReward>(), Array.Empty<GrantedReward>());
// 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<GrantedReward>();
var postState = new List<GrantedReward>();
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<BattlePassPointGrant> AddPointsAsync(
@@ -225,14 +208,6 @@ public sealed class BattlePassService : IBattlePassService
Array.Empty<SVSim.Database.Services.GrantedReward>());
}
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<SVSim.Database.Services.GrantedReward>();
IReadOnlyList<SVSim.Database.Services.GrantedReward> newlyClaimed = Array.Empty<SVSim.Database.Services.GrantedReward>();
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);