diff --git a/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs b/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs index faec0ad..2059c91 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs @@ -28,4 +28,14 @@ public class BattlePassController : SVSimController if (info is null) return Ok(new { }); // off-season: empty payload return Ok(info); } + + [HttpPost("item_list")] + public async Task ItemList(BaseRequest request, CancellationToken ct) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + + var list = await _battlePass.GetItemListAsync(viewerId, ct); + if (list is null) return Ok(new { }); + return Ok(list); + } } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassItemListResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassItemListResponse.cs new file mode 100644 index 0000000..4c7a090 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassItemListResponse.cs @@ -0,0 +1,25 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; + +/// +/// /battle_pass/item_list response (Wizard/BattlePassPurchaseInfoTask.cs:23-44). +/// +[MessagePackObject] +public class BattlePassItemListResponse +{ + [JsonPropertyName("premium_pass_description")] + [Key("premium_pass_description")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string PremiumPassDescription { get; set; } = ""; + + [JsonPropertyName("sales_period_info")] + [Key("sales_period_info")] + public BattlePassSalesPeriodInfoDto? SalesPeriodInfo { get; set; } + + [JsonPropertyName("products")] + [Key("products")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public List Products { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassProductDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassProductDto.cs new file mode 100644 index 0000000..42f675d --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassProductDto.cs @@ -0,0 +1,43 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; + +/// +/// One product in /battle_pass/item_list.products[] (Wizard/BattlePassPurchaseInfoTask.cs:32-43). +/// Numerics on this DTO are numeric on the wire (the client uses .ToInt() and the captured +/// shape isn't string-typed here). +/// +[MessagePackObject] +public class BattlePassProductDto +{ + [JsonPropertyName("id")] + [Key("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public int Id { get; set; } + + [JsonPropertyName("season_id")] + [Key("season_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public int SeasonId { get; set; } + + [JsonPropertyName("name")] + [Key("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string Name { get; set; } = ""; + + [JsonPropertyName("price_crystal")] + [Key("price_crystal")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public int PriceCrystal { get; set; } + + [JsonPropertyName("description")] + [Key("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string Description { get; set; } = ""; + + [JsonPropertyName("sales_period_info")] + [Key("sales_period_info")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public BattlePassSalesPeriodInfoDto SalesPeriodInfo { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassSalesPeriodInfoDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassSalesPeriodInfoDto.cs new file mode 100644 index 0000000..9c34e35 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/BattlePass/BattlePassSalesPeriodInfoDto.cs @@ -0,0 +1,16 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; + +/// +/// sales_period_info on /battle_pass/item_list (Wizard/BattlePassPurchaseInfoTask.cs:26-29). +/// Only sales_period_time is read by the client; other fields are unused but allowed. +/// +[MessagePackObject] +public class BattlePassSalesPeriodInfoDto +{ + [JsonPropertyName("sales_period_time")] + [Key("sales_period_time")] + public string? SalesPeriodTime { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs index cf99248..da6c0e3 100644 --- a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs @@ -94,6 +94,44 @@ public sealed class BattlePassService : IBattlePassService }; } + public async Task GetItemListAsync(long viewerId, CancellationToken ct) + { + var now = _time.GetUtcNow(); + var season = await _bp.GetActiveSeasonAsync(now, ct); + if (season is null) return null; + + var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct); + + var response = new BattlePassItemListResponse + { + PremiumPassDescription = season.Description, + SalesPeriodInfo = new BattlePassSalesPeriodInfoDto + { + SalesPeriodTime = FormatWireDate(season.EndDate), + }, + Products = new List(), + }; + + // One product per active season; empty if viewer is already premium. + if (!progress.IsPremium && season.CanPurchase) + { + response.Products.Add(new BattlePassProductDto + { + Id = season.Id * 1000, + SeasonId = season.Id, + Name = $"{season.Name} Premium Pass", + PriceCrystal = season.PriceCrystal, + Description = season.Description, + SalesPeriodInfo = new BattlePassSalesPeriodInfoDto + { + SalesPeriodTime = FormatWireDate(season.EndDate), + }, + }); + } + + return response; + } + 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 8df765d..645800b 100644 --- a/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs @@ -13,4 +13,10 @@ public interface IBattlePassService /// (controller emits empty body in that case). /// Task GetInfoAsync(long viewerId, CancellationToken ct); + + /// + /// /battle_pass/item_list payload. Returns one product per active season; empty products + /// array if the viewer already owns premium for the active season. Null when no active season. + /// + Task GetItemListAsync(long viewerId, CancellationToken ct); } diff --git a/SVSim.UnitTests/Controllers/BattlePassControllerItemListTests.cs b/SVSim.UnitTests/Controllers/BattlePassControllerItemListTests.cs new file mode 100644 index 0000000..c82b087 --- /dev/null +++ b/SVSim.UnitTests/Controllers/BattlePassControllerItemListTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class BattlePassControllerItemListTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + private const string EmptyAuthBody = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + + private static async Task SeedSeason23(SVSimTestFactory f) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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 = "Unlock premium track.", + }); + await db.SaveChangesAsync(); + } + + [Test] + public async Task ItemList_returns_one_product_for_active_season() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedSeason23(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/battle_pass/item_list", JsonBody(EmptyAuthBody)); + var body = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + using var doc = JsonDocument.Parse(body); + var products = doc.RootElement.GetProperty("products"); + Assert.That(products.GetArrayLength(), Is.EqualTo(1)); + var product = products[0]; + Assert.That(product.GetProperty("id").GetInt32(), Is.EqualTo(23000)); // 23 * 1000 + Assert.That(product.GetProperty("season_id").GetInt32(), Is.EqualTo(23)); + Assert.That(product.GetProperty("price_crystal").GetInt32(), Is.EqualTo(980)); + } + + [Test] + public async Task ItemList_returns_empty_products_when_viewer_already_premium() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedSeason23(factory); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ViewerBattlePassProgress.Add(new ViewerBattlePassProgressEntry + { + ViewerId = viewerId, SeasonId = 23, CurrentPoint = 0, + IsPremium = true, WeeklyPoints = 0, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/battle_pass/item_list", JsonBody(EmptyAuthBody)); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.GetProperty("products").GetArrayLength(), Is.EqualTo(0)); + } +}