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