feat(bp): /battle_pass/buy — crystal-cost + retroactive premium grants

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-26 23:36:18 -04:00
parent 0ceab721e9
commit 2cb8c271a8
9 changed files with 422 additions and 1 deletions

View File

@@ -38,4 +38,35 @@ public class BattlePassController : SVSimController
if (list is null) return Ok(new { });
return Ok(list);
}
[HttpPost("buy")]
public async Task<IActionResult> Buy(BattlePassBuyRequest request, CancellationToken ct)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var outcome = await _battlePass.BuyPremiumAsync(viewerId, request.SeasonId, request.Id, ct);
var response = new BattlePassBuyResponse
{
ResultCode = outcome.ResultCode,
AchievedInfo = new BattlePassAchievedInfoDto
{
BattlePassRewardList = outcome.AchievedRewards
.Select(g => new BattlePassReceivedRewardDto
{
RewardType = g.RewardType,
RewardDetailId = g.RewardId,
RewardNumber = g.RewardNum,
}).ToList(),
},
RewardList = outcome.PostStateTotals
.Select(g => new BattlePassRewardListEntryDto
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
}).ToList(),
};
return Ok(response);
}
}

View File

@@ -0,0 +1,17 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
/// <summary>
/// achieved_info wrapper inside /battle_pass/buy response
/// (Wizard/BattlePassBuyTask.cs:37-40).
/// </summary>
[MessagePackObject]
public class BattlePassAchievedInfoDto
{
[JsonPropertyName("battle_pass_reward_list")]
[Key("battle_pass_reward_list")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public List<BattlePassReceivedRewardDto> BattlePassRewardList { get; set; } = new();
}

View File

@@ -0,0 +1,22 @@
using MessagePack;
using System.Text.Json.Serialization;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
/// <summary>
/// /battle_pass/buy request (Wizard/BattlePassBuyTask.cs:8-13). Inherits viewer_id, steam_id,
/// steam_session_ticket from BaseRequest. Per memory feedback_msgpack_request_dtos, the
/// [MessagePackObject] + [Key] attrs are required even though integration tests post JSON.
/// </summary>
[MessagePackObject]
public class BattlePassBuyRequest : BaseRequest
{
[JsonPropertyName("season_id")]
[Key("season_id")]
public int SeasonId { get; set; }
[JsonPropertyName("id")]
[Key("id")]
public int Id { get; set; }
}

View File

@@ -0,0 +1,28 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
/// <summary>
/// /battle_pass/buy response (Wizard/BattlePassBuyTask.cs:30-52). result_code carries the
/// envelope failure path: 22 = insufficient crystals, 23 = already premium,
/// 24 = outside BP period / season mismatch.
/// </summary>
[MessagePackObject]
public class BattlePassBuyResponse
{
[JsonPropertyName("result_code")]
[Key("result_code")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public int ResultCode { get; set; } = 1;
[JsonPropertyName("achieved_info")]
[Key("achieved_info")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public BattlePassAchievedInfoDto AchievedInfo { get; set; } = new();
[JsonPropertyName("reward_list")]
[Key("reward_list")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public List<BattlePassRewardListEntryDto> RewardList { get; set; } = new();
}

View File

@@ -0,0 +1,27 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
/// <summary>
/// One entry in /battle_pass/buy → achieved_info.battle_pass_reward_list[]
/// (Wizard/BattlePassBuyTask.cs:42-48). Delta entries; numerics are int here, not string-typed.
/// </summary>
[MessagePackObject]
public class BattlePassReceivedRewardDto
{
[JsonPropertyName("reward_type")]
[Key("reward_type")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")]
[Key("reward_detail_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public long RewardDetailId { get; set; }
[JsonPropertyName("reward_number")]
[Key("reward_number")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public int RewardNumber { get; set; }
}

View File

@@ -0,0 +1,28 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
/// <summary>
/// One entry in /battle_pass/buy → reward_list[]. POST-STATE TOTALS for affected goods +
/// currency, per memory project_wire_reward_list_post_state. Matches RewardListEntry shape
/// used by /pack/open etc.
/// </summary>
[MessagePackObject]
public class BattlePassRewardListEntryDto
{
[JsonPropertyName("reward_type")]
[Key("reward_type")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public int RewardType { get; set; }
[JsonPropertyName("reward_id")]
[Key("reward_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public long RewardId { get; set; }
[JsonPropertyName("reward_num")]
[Key("reward_num")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public int RewardNum { get; set; }
}

View File

@@ -1,8 +1,10 @@
using System.Globalization;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.BattlePass;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
@@ -20,17 +22,20 @@ public sealed class BattlePassService : IBattlePassService
private readonly IViewerBattlePassRepository _viewerBp;
private readonly TimeProvider _time;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
public BattlePassService(
IBattlePassRepository bp,
IViewerBattlePassRepository viewerBp,
TimeProvider time,
SVSimDbContext db)
SVSimDbContext db,
RewardGrantService rewards)
{
_bp = bp;
_viewerBp = viewerBp;
_time = time;
_db = db;
_rewards = rewards;
}
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
@@ -132,6 +137,77 @@ public sealed class BattlePassService : IBattlePassService
return response;
}
public async Task<BattlePassBuyOutcome> BuyPremiumAsync(
long viewerId, int seasonId, int productId, CancellationToken ct)
{
var now = _time.GetUtcNow();
var season = await _bp.GetActiveSeasonAsync(now, ct);
// 24: outside BP period, season mismatch, or season not currently purchasable.
if (season is null || season.Id != seasonId || !season.CanPurchase)
return new BattlePassBuyOutcome(24, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
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)
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>());
if (viewer.Currency.Crystals < (ulong)season.PriceCrystal)
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);
viewer.Currency.Crystals -= (ulong)season.PriceCrystal;
progress.IsPremium = true;
// Retroactive grants: every premium reward at level <= current_level not already claimed.
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();
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 _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
// Post-state reward_list must also include the crystal balance after the deduction.
if (!postState.Any(r => r.RewardType == (int)UserGoodsType.Crystal))
{
postState.Add(new GrantedReward(
(int)UserGoodsType.Crystal, 0, checked((int)viewer.Currency.Crystals)));
}
return new BattlePassBuyOutcome(1, achieved, postState);
}
internal static int ComputeLevel(IReadOnlyList<BattlePassLevelEntry> curve, int point)
{
if (curve.Count == 0) return 1;

View File

@@ -19,4 +19,12 @@ public interface IBattlePassService
/// array if the viewer already owns premium for the active season. Null when no active season.
/// </summary>
Task<BattlePassItemListResponse?> GetItemListAsync(long viewerId, CancellationToken ct);
/// <summary>
/// Purchase premium for the active season. Validates season_id matches active season,
/// product id derives from season, viewer has crystals, viewer isn't already premium.
/// On success: deducts crystals, flips IsPremium, retroactively grants premium rewards for
/// every level &lt;= current_level not yet claimed. All-or-nothing transaction.
/// </summary>
Task<BattlePassBuyOutcome> BuyPremiumAsync(long viewerId, int seasonId, int productId, CancellationToken ct);
}