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 { // Default cap mirrors the captured /battle_pass/info.gauge_info.weekly_limit_point. public const int WeeklyLimitPointDefault = 3000; 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) { var rows = await _bp.GetLevelCurveAsync(ct); if (rows.Count == 0) return null; return rows.ToDictionary( 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); }