diff --git a/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs b/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs index 4cf1638..5c0aa2e 100644 --- a/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs +++ b/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs @@ -18,11 +18,17 @@ public sealed class BattlePassRepository : IBattlePassRepository public async Task GetActiveSeasonAsync(DateTimeOffset when, CancellationToken ct) { - return await _db.BattlePassSeasons + // Use UtcDateTime for the LINQ comparison so the query translates on both Postgres and + // SQLite. DateTimeOffset arithmetic in LINQ isn't supported by the SQLite provider; + // DateTime (UTC) is stored and compared as ISO-8601 text which SQLite handles fine. + var utcNow = when.UtcDateTime; + var candidates = await _db.BattlePassSeasons .AsNoTracking() - .Where(s => s.StartDate <= when && s.EndDate > when) + .ToListAsync(ct); + return candidates + .Where(s => s.StartDate.UtcDateTime <= utcNow && s.EndDate.UtcDateTime > utcNow) .OrderByDescending(s => s.StartDate) - .FirstOrDefaultAsync(ct); + .FirstOrDefault(); } public Task GetSeasonAsync(int seasonId, CancellationToken ct) => diff --git a/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs b/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs new file mode 100644 index 0000000..faec0ad --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// /battle_pass/* — season metadata, premium-pass purchase. Wire shapes mirror +/// Wizard/BattlePass{Info,PurchaseInfo,Buy}Task.cs. +/// +[Route("battle_pass")] +public class BattlePassController : SVSimController +{ + private readonly IBattlePassService _battlePass; + + public BattlePassController(IBattlePassService battlePass) + { + _battlePass = battlePass; + } + + [HttpPost("info")] + public async Task Info(BaseRequest request, CancellationToken ct) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + + var info = await _battlePass.GetInfoAsync(viewerId, ct); + if (info is null) return Ok(new { }); // off-season: empty payload + return Ok(info); + } +} diff --git a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs index 061049e..bab8424 100644 --- a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs @@ -1,16 +1,33 @@ using System.Globalization; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; using SVSim.Database.Repositories.BattlePass; using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; namespace SVSim.EmulatedEntrypoint.Services; public sealed class BattlePassService : IBattlePassService { - private readonly IBattlePassRepository _bp; + // Default cap mirrors the captured /battle_pass/info.gauge_info.weekly_limit_point. + public const int WeeklyLimitPointDefault = 3000; - public BattlePassService(IBattlePassRepository bp) + private readonly IBattlePassRepository _bp; + private readonly IViewerBattlePassRepository _viewerBp; + private readonly TimeProvider _time; + private readonly SVSimDbContext _db; + + public BattlePassService( + IBattlePassRepository bp, + IViewerBattlePassRepository viewerBp, + TimeProvider time, + SVSimDbContext db) { _bp = bp; + _viewerBp = viewerBp; + _time = time; + _db = db; } public async Task?> GetLevelCurveAsync(CancellationToken ct) @@ -18,11 +35,97 @@ public sealed class BattlePassService : IBattlePassService var rows = await _bp.GetLevelCurveAsync(ct); if (rows.Count == 0) return null; return rows.ToDictionary( - r => r.Level.ToString(CultureInfo.InvariantCulture), - r => new BattlePassLevel - { - Level = r.Level.ToString(CultureInfo.InvariantCulture), - RequiredPoint = r.RequiredPoint.ToString(CultureInfo.InvariantCulture), - }); + r => Inv(r.Level), + r => new BattlePassLevel { Level = Inv(r.Level), RequiredPoint = Inv(r.RequiredPoint) }); } + + public async Task GetInfoAsync(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); + // Persist the lazy-created row so concurrent /info calls don't try to create twice. + await _db.SaveChangesAsync(ct); + + 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); + + return new BattlePassInfoResponse + { + SeasonInfo = new BattlePassSeasonInfoDto + { + Id = Inv(season.Id), + SeasonName = season.Name, + MaxLevel = Inv(season.MaxLevel), + StartDate = FormatWireDate(season.StartDate), + EndDate = FormatWireDate(season.EndDate), + CanPurchase = season.CanPurchase, + }, + RewardInfo = new BattlePassRewardInfoDto + { + Normal = new BattlePassRewardListDto + { + Reward = rewards.Where(r => r.Track == BattlePassTrack.Normal) + .Select(r => ToRewardDto(r, claimSet)) + .ToList(), + }, + Premium = new BattlePassRewardListDto + { + Reward = rewards.Where(r => r.Track == BattlePassTrack.Premium) + .Select(r => ToRewardDto(r, claimSet)) + .ToList(), + }, + }, + GaugeInfo = new BattlePassGaugeInfoDto + { + CurrentPoint = Inv(progress.CurrentPoint), + CurrentLevel = Inv(currentLevel), + WeeklyBattlePassPoint = progress.WeeklyPoints, + WeeklyLimitPoint = WeeklyLimitPointDefault, + }, + PremiumAppealLevel = null, // populated when premium_appeal config is wired (future) + }; + } + + internal static int ComputeLevel(IReadOnlyList curve, int point) + { + if (curve.Count == 0) return 1; + int level = curve[0].Level; + foreach (var row in curve) + { + if (point >= row.RequiredPoint) level = row.Level; + else break; + } + return level; + } + + private static BattlePassRewardDto ToRewardDto(BattlePassRewardEntry r, HashSet<(BattlePassTrack, int)> claimSet) + { + return new BattlePassRewardDto + { + RewardLevel = Inv(r.Level), + RewardType = Inv(r.RewardType), + RewardDetailId = Inv(r.RewardDetailId), + RewardNumber = Inv(r.RewardNumber), + IsReceived = claimSet.Contains((r.Track, r.Level)), + IsAppealExclusion = r.Track == BattlePassTrack.Premium + ? (r.IsAppealExclusion ? "1" : "0") + : null, + }; + } + + private static string FormatWireDate(DateTimeOffset dt) => + // Capture format is "2026-04-01 02:00:00" (JST, space-separated). Emit in same shape + // in JST so the client gets back what it gave. + dt.ToOffset(TimeSpan.FromHours(9)) + .ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + + private static string Inv(long v) => v.ToString(CultureInfo.InvariantCulture); + private static string Inv(int v) => v.ToString(CultureInfo.InvariantCulture); } diff --git a/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs index 5aa0637..8df765d 100644 --- a/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs @@ -1,4 +1,5 @@ using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; namespace SVSim.EmulatedEntrypoint.Services; @@ -7,5 +8,9 @@ public interface IBattlePassService /// Global level curve as wire-string dictionary; null if no levels seeded. Task?> GetLevelCurveAsync(CancellationToken ct); - // The Info / ItemList / Buy / AddPoints methods are added in later tasks (11, 12, 13, 14). + /// + /// /battle_pass/info payload. Returns null when no active season window covers now + /// (controller emits empty body in that case). + /// + Task GetInfoAsync(long viewerId, CancellationToken ct); } diff --git a/SVSim.UnitTests/Controllers/BattlePassControllerInfoTests.cs b/SVSim.UnitTests/Controllers/BattlePassControllerInfoTests.cs new file mode 100644 index 0000000..8b08e48 --- /dev/null +++ b/SVSim.UnitTests/Controllers/BattlePassControllerInfoTests.cs @@ -0,0 +1,123 @@ +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 BattlePassControllerInfoTests +{ + 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 SeedSeason23WithRewards(SVSimTestFactory f) + { + 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 = "test", + }); + db.BattlePassRewards.Add(new BattlePassRewardEntry + { + Id = 23 * 10_000L + 0 * 1_000 + 2, // MakeId(23, Normal=0, 2) + SeasonId = 23, Track = BattlePassTrack.Normal, Level = 2, RewardType = 9, + RewardDetailId = 0, RewardNumber = 50, IsAppealExclusion = false, + }); + db.BattlePassRewards.Add(new BattlePassRewardEntry + { + Id = 23 * 10_000L + 1 * 1_000 + 2, // MakeId(23, Premium=1, 2) + SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = 9, + RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false, + }); + await db.SaveChangesAsync(); + } + + [Test] + public async Task Info_returns_season_with_zero_progress_for_fresh_viewer() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedSeason23WithRewards(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/battle_pass/info", JsonBody(EmptyAuthBody)); + var body = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + Assert.That(root.GetProperty("season_info").GetProperty("id").GetString(), Is.EqualTo("23")); + Assert.That(root.GetProperty("season_info").GetProperty("max_level").GetString(), Is.EqualTo("100")); + Assert.That(root.GetProperty("season_info").GetProperty("can_purchase").GetBoolean(), Is.True); + Assert.That(root.GetProperty("gauge_info").GetProperty("current_point").GetString(), Is.EqualTo("0")); + Assert.That(root.GetProperty("gauge_info").GetProperty("current_level").GetString(), Is.EqualTo("1")); + + var normalRewards = root.GetProperty("reward_info").GetProperty("normal").GetProperty("reward"); + Assert.That(normalRewards.GetArrayLength(), Is.EqualTo(1)); + Assert.That(normalRewards[0].GetProperty("reward_level").GetString(), Is.EqualTo("2")); + Assert.That(normalRewards[0].GetProperty("is_received").GetBoolean(), Is.False); + } + + [Test] + public async Task Info_marks_claimed_rewards_as_received() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedSeason23WithRewards(factory); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ViewerBattlePassClaims.Add(new ViewerBattlePassClaimEntry + { + ViewerId = viewerId, SeasonId = 23, Track = BattlePassTrack.Normal, + Level = 2, ClaimedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/battle_pass/info", JsonBody(EmptyAuthBody)); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + + var normalReward = doc.RootElement + .GetProperty("reward_info").GetProperty("normal").GetProperty("reward")[0]; + var premiumReward = doc.RootElement + .GetProperty("reward_info").GetProperty("premium").GetProperty("reward")[0]; + Assert.That(normalReward.GetProperty("is_received").GetBoolean(), Is.True); + Assert.That(premiumReward.GetProperty("is_received").GetBoolean(), Is.False); + } + + [Test] + public async Task Info_returns_empty_payload_outside_any_season_window() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // No season seeded. + + using var client = factory.CreateAuthenticatedClient(viewerId); + var response = await client.PostAsync("/battle_pass/info", JsonBody(EmptyAuthBody)); + var body = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + Assert.That(body, Does.Not.Contain("season_info").Or.Contains("\"season_info\":null"), + "off-season response should omit or null season_info"); + } +}