diff --git a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs index 0707e94..136bdf7 100644 --- a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs @@ -208,6 +208,89 @@ public sealed class BattlePassService : IBattlePassService return new BattlePassBuyOutcome(1, achieved, postState); } + public async Task AddPointsAsync( + long viewerId, BattlePassPointSource source, int amount, CancellationToken ct) + { + var now = _time.GetUtcNow(); + var season = await _bp.GetActiveSeasonAsync(now, ct); + if (season is null) + { + return new BattlePassPointGrant(0, 0, 0, 0, 0, source, + 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() + .FirstOrDefaultAsync(v => v.Id == viewerId, ct) + ?? throw new InvalidOperationException($"viewer {viewerId} not found"); + + var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct); + + int beforePoint = progress.CurrentPoint; + var curve = await _bp.GetLevelCurveAsync(ct); + int beforeLevel = ComputeLevel(curve, beforePoint); + + RolloverWeeklyIfNeeded(progress, now); + int headroom = Math.Max(0, WeeklyLimitPointDefault - progress.WeeklyPoints); + int capped = Math.Max(0, Math.Min(amount, headroom)); + + progress.CurrentPoint += capped; + progress.WeeklyPoints += capped; + + int afterLevel = ComputeLevel(curve, progress.CurrentPoint); + + var newlyClaimed = new List(); + if (afterLevel > beforeLevel) + { + 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(); + + for (int level = beforeLevel + 1; level <= afterLevel; level++) + { + foreach (var r in rewards.Where(r => r.Level == level)) + { + if (r.Track == BattlePassTrack.Premium && !progress.IsPremium) continue; + 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); + newlyClaimed.AddRange(granted); + } + } + } + + await _db.SaveChangesAsync(ct); + + return new BattlePassPointGrant( + BeforePoint: beforePoint, + BeforeLevel: beforeLevel, + AfterPoint: progress.CurrentPoint, + AfterLevel: afterLevel, + PointAdd: capped, + Source: source, + NewlyClaimed: newlyClaimed); + } + + private static void RolloverWeeklyIfNeeded(ViewerBattlePassProgressEntry progress, DateTimeOffset now) + { + // Open question (see spec "Open assumptions"): true Cygames boundary likely ties to a fixed + // weekday/timezone. v1 uses a per-viewer 7-day sliding window from first grant. + if (progress.WeeklyPeriodStart is null) + { + progress.WeeklyPeriodStart = now; + return; + } + if (now - progress.WeeklyPeriodStart.Value >= TimeSpan.FromDays(7)) + { + progress.WeeklyPeriodStart = now; + progress.WeeklyPoints = 0; + } + } + 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 0c790db..a342eaf 100644 --- a/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/IBattlePassService.cs @@ -5,6 +5,16 @@ namespace SVSim.EmulatedEntrypoint.Services; public interface IBattlePassService { + /// + /// Plumbing for future point-source endpoints (mission/retire, battle finish handlers). + /// Bumps gauge by min(amount, weekly headroom), auto-grants rewards on every level crossed + /// (premium track only when IsPremium), writes claim rows + currency/collection mutations + /// via RewardGrantService. Returns delta info for caller to embed in + /// battle_pass_gauge_info on its response. No live caller in v1; tested directly. + /// + Task AddPointsAsync( + long viewerId, BattlePassPointSource source, int amount, CancellationToken ct); + /// Global level curve as wire-string dictionary; null if no levels seeded. Task?> GetLevelCurveAsync(CancellationToken ct); diff --git a/SVSim.UnitTests/Services/BattlePassServiceTests.cs b/SVSim.UnitTests/Services/BattlePassServiceTests.cs new file mode 100644 index 0000000..da9404d --- /dev/null +++ b/SVSim.UnitTests/Services/BattlePassServiceTests.cs @@ -0,0 +1,129 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Repositories.BattlePass; +using SVSim.EmulatedEntrypoint.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class BattlePassServiceTests +{ + // 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 SeedViewerAndSeason23(SVSimTestFactory f, bool isPremium = false) + { + long viewerId = await f.SeedViewerAsync(); + // Reset process-level level-curve cache so this test's seeded rows are visible. + BattlePassRepository.ResetLevelCurveCache(); + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // Zero out rupees so post-state totals in reward assertions equal the delta amounts. + var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); + viewer.Currency.Rupees = 0; + await db.SaveChangesAsync(); + 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 = "", + }); + // Normal level-2 = rupy 50; premium level-2 = rupy 20. + db.BattlePassRewards.Add(new BattlePassRewardEntry + { + Id = MakeRewardId(23, BattlePassTrack.Normal, 2), + SeasonId = 23, Track = BattlePassTrack.Normal, Level = 2, RewardType = 9, + RewardDetailId = 0, RewardNumber = 50, IsAppealExclusion = false, + }); + 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, + }); + if (isPremium) + { + db.ViewerBattlePassProgress.Add(new ViewerBattlePassProgressEntry + { + ViewerId = viewerId, SeasonId = 23, IsPremium = true, + }); + } + await db.SaveChangesAsync(); + return viewerId; + } + + [Test] + public async Task AddPoints_crossing_one_level_grants_normal_reward_only_when_not_premium() + { + using var factory = new SVSimTestFactory(); + long viewerId = await SeedViewerAndSeason23(factory, isPremium: false); + + using var scope = factory.Services.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + + var grant = await svc.AddPointsAsync(viewerId, BattlePassPointSource.BattleResult, 500, CancellationToken.None); + + Assert.That(grant.BeforeLevel, Is.EqualTo(1)); + Assert.That(grant.AfterLevel, Is.EqualTo(2)); + Assert.That(grant.PointAdd, Is.EqualTo(500)); + Assert.That(grant.NewlyClaimed.Count, Is.EqualTo(1), "premium level-2 must be skipped"); + Assert.That(grant.NewlyClaimed[0].RewardType, Is.EqualTo((int)UserGoodsType.Rupy)); + Assert.That(grant.NewlyClaimed[0].RewardNum, Is.EqualTo(50), "post-state rupy total"); + } + + [Test] + public async Task AddPoints_with_premium_grants_both_tracks_at_level_crossed() + { + using var factory = new SVSimTestFactory(); + long viewerId = await SeedViewerAndSeason23(factory, isPremium: true); + + using var scope = factory.Services.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + + var grant = await svc.AddPointsAsync(viewerId, BattlePassPointSource.BattleResult, 500, CancellationToken.None); + + Assert.That(grant.NewlyClaimed.Count, Is.EqualTo(2)); + } + + [Test] + public async Task AddPoints_weekly_cap_caps_second_grant_to_remaining_headroom() + { + using var factory = new SVSimTestFactory(); + long viewerId = await SeedViewerAndSeason23(factory); + + using var scope = factory.Services.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + + var first = await svc.AddPointsAsync(viewerId, BattlePassPointSource.BattleResult, 2000, CancellationToken.None); + var second = await svc.AddPointsAsync(viewerId, BattlePassPointSource.BattleResult, 2000, CancellationToken.None); + + Assert.That(first.PointAdd, Is.EqualTo(2000)); + Assert.That(second.PointAdd, Is.EqualTo(1000), + $"cap = {BattlePassService.WeeklyLimitPointDefault}; first burned 2000; second caps to 1000"); + } + + [Test] + public async Task AddPoints_when_no_season_active_returns_zero_grant() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + + var grant = await svc.AddPointsAsync(viewerId, BattlePassPointSource.BattleResult, 500, CancellationToken.None); + + Assert.That(grant.PointAdd, Is.EqualTo(0)); + Assert.That(grant.AfterLevel, Is.EqualTo(0)); + Assert.That(grant.NewlyClaimed, Is.Empty); + } +}