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.Models;
|
||||||
using SVSim.Database.Repositories.BattlePass;
|
using SVSim.Database.Repositories.BattlePass;
|
||||||
using SVSim.Database.Services;
|
using SVSim.Database.Services;
|
||||||
|
using SVSim.Database.Services.Inventory;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||||
|
|
||||||
@@ -22,23 +23,20 @@ public sealed class BattlePassService : IBattlePassService
|
|||||||
private readonly IViewerBattlePassRepository _viewerBp;
|
private readonly IViewerBattlePassRepository _viewerBp;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
private readonly SVSimDbContext _db;
|
private readonly SVSimDbContext _db;
|
||||||
private readonly RewardGrantService _rewards;
|
private readonly IInventoryService _inv;
|
||||||
private readonly ICurrencySpendService _spend;
|
|
||||||
|
|
||||||
public BattlePassService(
|
public BattlePassService(
|
||||||
IBattlePassRepository bp,
|
IBattlePassRepository bp,
|
||||||
IViewerBattlePassRepository viewerBp,
|
IViewerBattlePassRepository viewerBp,
|
||||||
TimeProvider time,
|
TimeProvider time,
|
||||||
SVSimDbContext db,
|
SVSimDbContext db,
|
||||||
RewardGrantService rewards,
|
IInventoryService inv)
|
||||||
ICurrencySpendService spend)
|
|
||||||
{
|
{
|
||||||
_bp = bp;
|
_bp = bp;
|
||||||
_viewerBp = viewerBp;
|
_viewerBp = viewerBp;
|
||||||
_time = time;
|
_time = time;
|
||||||
_db = db;
|
_db = db;
|
||||||
_rewards = rewards;
|
_inv = inv;
|
||||||
_spend = spend;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
|
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
|
||||||
@@ -156,26 +154,22 @@ public sealed class BattlePassService : IBattlePassService
|
|||||||
if (productId != season.Id * 1000)
|
if (productId != season.Id * 1000)
|
||||||
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
||||||
|
|
||||||
var viewer = await _db.Viewers
|
// Guard: viewer must exist (BeginAsync throws InventoryViewerNotFoundException otherwise).
|
||||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
var viewerExists = await _db.Viewers.AnyAsync(v => v.Id == viewerId, ct);
|
||||||
.Include(v => v.Sleeves).Include(v => v.Emblems).Include(v => v.LeaderSkins)
|
if (!viewerExists)
|
||||||
.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)
|
|
||||||
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
||||||
|
|
||||||
var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct);
|
var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct);
|
||||||
if (progress.IsPremium)
|
if (progress.IsPremium)
|
||||||
return new BattlePassBuyOutcome(23, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
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)
|
if (!spendResult.Success)
|
||||||
return new BattlePassBuyOutcome(22, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
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;
|
progress.IsPremium = true;
|
||||||
|
|
||||||
// Retroactive grants: every premium reward at level <= current_level not already claimed.
|
// 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);
|
var curve = await _bp.GetLevelCurveAsync(ct);
|
||||||
int currentLevel = ComputeLevel(curve, progress.CurrentPoint);
|
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))
|
foreach (var r in rewards.Where(r => r.Track == BattlePassTrack.Premium && r.Level <= currentLevel))
|
||||||
{
|
{
|
||||||
if (claimSet.Contains((r.Track, r.Level))) continue;
|
if (claimSet.Contains((r.Track, r.Level))) continue;
|
||||||
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
||||||
var granted = await _rewards.ApplyAsync(
|
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
||||||
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 _db.SaveChangesAsync(ct);
|
// CommitAsync handles DB save + currency-collision rule. Crystal spend is the first
|
||||||
await tx.CommitAsync(ct);
|
// 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.
|
return new BattlePassBuyOutcome(1, result.Deltas, result.RewardList);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BattlePassPointGrant> AddPointsAsync(
|
public async Task<BattlePassPointGrant> AddPointsAsync(
|
||||||
@@ -225,14 +208,6 @@ public sealed class BattlePassService : IBattlePassService
|
|||||||
Array.Empty<SVSim.Database.Services.GrantedReward>());
|
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);
|
var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct);
|
||||||
|
|
||||||
int beforePoint = progress.CurrentPoint;
|
int beforePoint = progress.CurrentPoint;
|
||||||
@@ -248,13 +223,15 @@ public sealed class BattlePassService : IBattlePassService
|
|||||||
|
|
||||||
int afterLevel = ComputeLevel(curve, progress.CurrentPoint);
|
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)
|
if (afterLevel > beforeLevel)
|
||||||
{
|
{
|
||||||
var rewards = await _bp.GetSeasonRewardsAsync(season.Id, ct);
|
var rewards = await _bp.GetSeasonRewardsAsync(season.Id, ct);
|
||||||
var claims = await _viewerBp.GetClaimsAsync(viewerId, season.Id, ct);
|
var claims = await _viewerBp.GetClaimsAsync(viewerId, season.Id, ct);
|
||||||
var claimSet = claims.Select(c => (c.Track, c.Level)).ToHashSet();
|
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++)
|
for (int level = beforeLevel + 1; level <= afterLevel; level++)
|
||||||
{
|
{
|
||||||
foreach (var r in rewards.Where(r => r.Level == 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 (r.Track == BattlePassTrack.Premium && !progress.IsPremium) continue;
|
||||||
if (claimSet.Contains((r.Track, r.Level))) continue;
|
if (claimSet.Contains((r.Track, r.Level))) continue;
|
||||||
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
||||||
var granted = await _rewards.ApplyAsync(
|
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
||||||
viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
|
||||||
newlyClaimed.AddRange(granted);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var result = await tx.CommitAsync(ct);
|
||||||
|
newlyClaimed = result.Deltas;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|||||||
Reference in New Issue
Block a user