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; 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; /// JST = UTC+9. Capture format ("2026-04-01 02:00:00") is implicit JST. private static readonly TimeSpan JstOffset = TimeSpan.FromHours(9); private readonly IBattlePassRepository _bp; 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, RewardGrantService rewards) { _bp = bp; _viewerBp = viewerBp; _time = time; _db = db; _rewards = rewards; } 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); 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) }; } 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; } 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 always include the crystal balance after the deduction. // Unconditionally overwrite: remove any crystal entry ApplyAsync may have added, then // append the post-deduction total so the client gets the correct final balance. postState.RemoveAll(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; 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(JstOffset) .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); }