diff --git a/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs b/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs index 2059c91..d136156 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs @@ -38,4 +38,35 @@ public class BattlePassController : SVSimController if (list is null) return Ok(new { }); return Ok(list); } + + [HttpPost("buy")] + public async Task 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); + } } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassAchievedInfoDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassAchievedInfoDto.cs new file mode 100644 index 0000000..1a9f4a2 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassAchievedInfoDto.cs @@ -0,0 +1,17 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; + +/// +/// achieved_info wrapper inside /battle_pass/buy response +/// (Wizard/BattlePassBuyTask.cs:37-40). +/// +[MessagePackObject] +public class BattlePassAchievedInfoDto +{ + [JsonPropertyName("battle_pass_reward_list")] + [Key("battle_pass_reward_list")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public List BattlePassRewardList { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassBuyRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassBuyRequest.cs new file mode 100644 index 0000000..172fb1c --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassBuyRequest.cs @@ -0,0 +1,22 @@ +using MessagePack; +using System.Text.Json.Serialization; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; + +/// +/// /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. +/// +[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; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassBuyResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassBuyResponse.cs new file mode 100644 index 0000000..270658d --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassBuyResponse.cs @@ -0,0 +1,28 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; + +/// +/// /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. +/// +[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 RewardList { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassReceivedRewardDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassReceivedRewardDto.cs new file mode 100644 index 0000000..947435a --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassReceivedRewardDto.cs @@ -0,0 +1,27 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; + +/// +/// 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. +/// +[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; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassRewardListEntryDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassRewardListEntryDto.cs new file mode 100644 index 0000000..68fa0db --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassRewardListEntryDto.cs @@ -0,0 +1,28 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; + +/// +/// 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. +/// +[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; } +} diff --git a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs index da6c0e3..db097cd 100644 --- a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs @@ -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?> GetLevelCurveAsync(CancellationToken ct) @@ -132,6 +137,77 @@ public sealed class BattlePassService : IBattlePassService return response; } + public async Task 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(), Array.Empty()); + + 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) + 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()); + + if (viewer.Currency.Crystals < (ulong)season.PriceCrystal) + 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); + + 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(); + 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 _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 curve, int point) { if (curve.Count == 0) return 1; diff --git a/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs index 645800b..0c790db 100644 --- a/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs @@ -19,4 +19,12 @@ public interface IBattlePassService /// array if the viewer already owns premium for the active season. Null when no active season. /// Task GetItemListAsync(long viewerId, CancellationToken ct); + + /// + /// 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 <= current_level not yet claimed. All-or-nothing transaction. + /// + Task BuyPremiumAsync(long viewerId, int seasonId, int productId, CancellationToken ct); } diff --git a/SVSim.UnitTests/Controllers/BattlePassControllerBuyTests.cs b/SVSim.UnitTests/Controllers/BattlePassControllerBuyTests.cs new file mode 100644 index 0000000..08f8c24 --- /dev/null +++ b/SVSim.UnitTests/Controllers/BattlePassControllerBuyTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class BattlePassControllerBuyTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + // Reward Id formula matches BattlePassRewardImporter.MakeId — keep in sync. + private static long MakeRewardId(int seasonId, BattlePassTrack track, int level) + => seasonId * 10_000L + (long)track * 1_000 + level; + + private static async Task SeedSeason23WithPremiumReward(SVSimTestFactory f, long viewerId, ulong viewerCrystals, int currentPoint) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (await db.BattlePassLevels.CountAsync() == 0) + { + for (int i = 1; i <= 100; i++) + db.BattlePassLevels.Add(new BattlePassLevelEntry { Level = i, RequiredPoint = (i - 1) * 500 }); + } + db.BattlePassSeasons.Add(new BattlePassSeasonEntry + { + Id = 23, Name = "Season 23", MaxLevel = 100, + StartDate = DateTimeOffset.UtcNow.AddDays(-30), + EndDate = DateTimeOffset.UtcNow.AddDays(30), + CanPurchase = true, PriceCrystal = 980, Description = "", + }); + db.BattlePassRewards.Add(new BattlePassRewardEntry + { + Id = MakeRewardId(23, BattlePassTrack.Premium, 2), + SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = 9, + RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false, + }); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = viewerCrystals; + await db.SaveChangesAsync(); + + if (currentPoint > 0) + { + var p = new ViewerBattlePassProgressEntry + { + ViewerId = viewerId, SeasonId = 23, CurrentPoint = currentPoint, + IsPremium = false, WeeklyPoints = 0, + }; + db.ViewerBattlePassProgress.Add(p); + await db.SaveChangesAsync(); + } + } + + [Test] + public async Task Buy_happy_path_at_level_1_deducts_crystals_and_emits_empty_achieved() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedSeason23WithPremiumReward(factory, viewerId, viewerCrystals: 1000, currentPoint: 0); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","season_id":23,"id":23000}"""; + var response = await client.PostAsync("/battle_pass/buy", JsonBody(req)); + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + var achievedList = doc.RootElement.GetProperty("achieved_info").GetProperty("battle_pass_reward_list"); + Assert.That(achievedList.GetArrayLength(), Is.EqualTo(0), + "level 1 → no premium rewards crossed"); + + bool foundCrystal = false; + foreach (var el in doc.RootElement.GetProperty("reward_list").EnumerateArray()) + { + if (el.GetProperty("reward_type").GetInt32() == 2) + { + foundCrystal = true; + Assert.That(el.GetProperty("reward_num").GetInt32(), Is.EqualTo(20), + "post-state crystal total = 1000 - 980"); + } + } + Assert.That(foundCrystal, Is.True, "reward_list must carry post-state crystal total"); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + var progress = await db.ViewerBattlePassProgress.SingleAsync(p => p.ViewerId == viewerId); + Assert.That(v.Currency.Crystals, Is.EqualTo(20UL)); + Assert.That(progress.IsPremium, Is.True); + } + + [Test] + public async Task Buy_at_level_2_includes_retroactive_premium_reward_in_achieved() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedSeason23WithPremiumReward(factory, viewerId, viewerCrystals: 1000, currentPoint: 500); // level 2 + + using var client = factory.CreateAuthenticatedClient(viewerId); + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","season_id":23,"id":23000}"""; + var response = await client.PostAsync("/battle_pass/buy", JsonBody(req)); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + + var achievedList = doc.RootElement.GetProperty("achieved_info").GetProperty("battle_pass_reward_list"); + Assert.That(achievedList.GetArrayLength(), Is.EqualTo(1), "premium level-2 reward must drop"); + Assert.That(achievedList[0].GetProperty("reward_type").GetInt32(), Is.EqualTo(9)); + Assert.That(achievedList[0].GetProperty("reward_number").GetInt32(), Is.EqualTo(20)); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + bool claimWritten = await db.ViewerBattlePassClaims.AnyAsync( + c => c.ViewerId == viewerId && c.SeasonId == 23 + && c.Track == BattlePassTrack.Premium && c.Level == 2); + Assert.That(claimWritten, Is.True, "claim row must be persisted"); + } + + [Test] + public async Task Buy_with_insufficient_crystals_returns_22_and_makes_no_changes() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedSeason23WithPremiumReward(factory, viewerId, viewerCrystals: 100, currentPoint: 0); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","season_id":23,"id":23000}"""; + var response = await client.PostAsync("/battle_pass/buy", JsonBody(req)); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(22)); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + Assert.That(v.Currency.Crystals, Is.EqualTo(100UL), "no crystal deduction on failure"); + bool anyProgress = await db.ViewerBattlePassProgress + .AnyAsync(p => p.ViewerId == viewerId && p.IsPremium); + Assert.That(anyProgress, Is.False); + } + + [Test] + public async Task Buy_when_already_premium_returns_23() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedSeason23WithPremiumReward(factory, viewerId, viewerCrystals: 5000, currentPoint: 0); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var p = await db.ViewerBattlePassProgress.SingleOrDefaultAsync(x => x.ViewerId == viewerId) + ?? new ViewerBattlePassProgressEntry { ViewerId = viewerId, SeasonId = 23 }; + p.IsPremium = true; + if (p.Id == 0) db.ViewerBattlePassProgress.Add(p); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","season_id":23,"id":23000}"""; + var response = await client.PostAsync("/battle_pass/buy", JsonBody(req)); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(23)); + } + + [Test] + public async Task Buy_outside_period_returns_24() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // No season seeded → active-season lookup returns null. + + using var client = factory.CreateAuthenticatedClient(viewerId); + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","season_id":23,"id":23000}"""; + var response = await client.PostAsync("/battle_pass/buy", JsonBody(req)); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(24)); + } +}