From 5083d43e51313e33e20205ab1a98ea9c8f9489de Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 13 Jun 2026 16:15:21 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20ILoginBonusService=20=E2=80=94=20JST=20?= =?UTF-8?q?day-bucketed=20claim=20+=20grant=20+=20DTO=20assembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GrantIfDueAsync advances streak (1..15 cycle), grants the day's reward via inventory tx, returns wire-shape DailyLoginBonus. IsDue helper for MyPage. Also fixes GameConfigurationJsonbTests section-count (15→16) which was broken since Task 3 added the LoginBonus [ConfigSection] but the assertion wasn't updated. --- SVSim.EmulatedEntrypoint/Program.cs | 1 + .../Services/ILoginBonusService.cs | 22 ++ .../Services/LoginBonusService.cs | 68 +++++++ .../Models/GameConfigurationJsonbTests.cs | 6 +- .../Services/LoginBonusServiceTests.cs | 190 ++++++++++++++++++ 5 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 SVSim.EmulatedEntrypoint/Services/ILoginBonusService.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/LoginBonusService.cs create mode 100644 SVSim.UnitTests/Services/LoginBonusServiceTests.cs diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 16a7ab6b..4c635f8b 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -113,6 +113,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/SVSim.EmulatedEntrypoint/Services/ILoginBonusService.cs b/SVSim.EmulatedEntrypoint/Services/ILoginBonusService.cs new file mode 100644 index 00000000..4d83a4e8 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/ILoginBonusService.cs @@ -0,0 +1,22 @@ +using SVSim.Database.Services.Inventory; +using SVSim.EmulatedEntrypoint.Models.Dtos; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// Daily login bonus controller-facing API. Single entry point: called from +/// /load/index inside the existing inventory transaction. Returns null when the viewer +/// has already claimed today's bonus. +/// +public interface ILoginBonusService +{ + /// + /// If due, advances tx.Viewer.LoginBonusStreak + LastLoginBonusClaimedAt, + /// grants the day's reward via , and returns the populated wire + /// DTO. If not due, returns null and leaves viewer state untouched. + /// + Task GrantIfDueAsync(IInventoryTransaction tx, CancellationToken ct = default); + + /// Read-only "would GrantIfDueAsync emit a bonus right now?" — for MyPage flag. + bool IsDue(SVSim.Database.Models.Viewer viewer); +} diff --git a/SVSim.EmulatedEntrypoint/Services/LoginBonusService.cs b/SVSim.EmulatedEntrypoint/Services/LoginBonusService.cs new file mode 100644 index 00000000..2c785d42 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/LoginBonusService.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using SVSim.Database.Models; +using SVSim.Database.Models.Config; +using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; +using SVSim.EmulatedEntrypoint.Models.Dtos; + +namespace SVSim.EmulatedEntrypoint.Services; + +public class LoginBonusService : ILoginBonusService +{ + private readonly IGameConfigService _config; + private readonly TimeProvider _time; + + public LoginBonusService(IGameConfigService config, TimeProvider time) + { + _config = config; + _time = time; + } + + public bool IsDue(Viewer viewer) + { + if (viewer.LastLoginBonusClaimedAt is null) return true; + var now = _time.GetUtcNow(); + var lastClaim = new DateTimeOffset( + DateTime.SpecifyKind(viewer.LastLoginBonusClaimedAt.Value, DateTimeKind.Utc)); + return JstPeriod.DayKey(lastClaim) != JstPeriod.DayKey(now); + } + + public async Task GrantIfDueAsync(IInventoryTransaction tx, CancellationToken ct = default) + { + var viewer = tx.Viewer; + if (!IsDue(viewer)) return null; + + var cfg = _config.Get(); + if (cfg.Normal.Count == 0) return null; // catalog misconfigured — nothing to grant + + int newStreak = (viewer.LoginBonusStreak % cfg.Normal.Count) + 1; + var entry = cfg.Normal[newStreak - 1]; + + await tx.GrantAsync(entry.RewardType, entry.RewardDetailId, entry.RewardNumber, ct); + + viewer.LoginBonusStreak = newStreak; + viewer.LastLoginBonusClaimedAt = _time.GetUtcNow().UtcDateTime; + + return new DailyLoginBonus + { + Normal = new LoginBonusCampaign + { + Name = cfg.Name, + CampaignId = cfg.CampaignId.ToString(CultureInfo.InvariantCulture), + Img = cfg.Img, + NowCount = newStreak, + IsNextReward = true, + IsOneDayMultiRewards = false, + Rewards = cfg.Normal.Select(n => new LoginBonusReward + { + EffectId = n.EffectId.ToString(CultureInfo.InvariantCulture), + RewardType = ((int)n.RewardType).ToString(CultureInfo.InvariantCulture), + RewardDetailId = n.RewardDetailId.ToString(CultureInfo.InvariantCulture), + RewardNumber = n.RewardNumber.ToString(CultureInfo.InvariantCulture), + }).ToList(), + }, + Total = null, + Campaign = new List(), + }; + } +} diff --git a/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs b/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs index fe9d7ee5..506ab09e 100644 --- a/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs +++ b/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs @@ -25,14 +25,14 @@ public class GameConfigurationJsonbTests var rows = await db.GameConfigs.AsNoTracking().ToListAsync(); var byName = rows.ToDictionary(r => r.SectionName); - // One row per [ConfigSection]-marked POCO (15 sections today: Player, DefaultGrants, + // One row per [ConfigSection]-marked POCO (16 sections today: Player, DefaultGrants, // DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig, - // Freeplay, ArenaTwoPick, Matching, CardMasterConfig, ColosseumSeason, ColosseumRounds). + // Freeplay, ArenaTwoPick, Matching, CardMasterConfig, ColosseumSeason, ColosseumRounds, LoginBonus). Assert.That(byName.Keys, Is.EquivalentTo(new[] { "Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates", "MyRotationSchedule", "Story", "ResourceConfig", "Freeplay", "ArenaTwoPick", "Matching", - "CardMasterConfig", "ColosseumSeason", "ColosseumRounds", + "CardMasterConfig", "ColosseumSeason", "ColosseumRounds", "LoginBonus", })); var resources = JsonSerializer.Deserialize(byName["ResourceConfig"].ValueJson)!; diff --git a/SVSim.UnitTests/Services/LoginBonusServiceTests.cs b/SVSim.UnitTests/Services/LoginBonusServiceTests.cs new file mode 100644 index 00000000..ac007f81 --- /dev/null +++ b/SVSim.UnitTests/Services/LoginBonusServiceTests.cs @@ -0,0 +1,190 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services.Inventory; +using SVSim.EmulatedEntrypoint.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class LoginBonusServiceTests +{ + private static async Task<(SVSimTestFactory factory, long viewerId)> SetupAsync() + { + var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(); + return (factory, viewerId); + } + + private static async Task ReloadViewerAsync(SVSimTestFactory f, long viewerId) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Viewers.Include(v => v.Currency).FirstAsync(v => v.Id == viewerId); + } + + [Test] + public async Task FirstClaim_returns_dto_with_now_count_1() + { + var (factory, viewerId) = await SetupAsync(); + using var _ = factory; + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + var bonus = scope.ServiceProvider.GetRequiredService(); + + await using var tx = await inv.BeginAsync(viewerId); + var dto = await bonus.GrantIfDueAsync(tx); + await tx.CommitAsync(); + + Assert.That(dto, Is.Not.Null); + Assert.That(dto!.Normal, Is.Not.Null); + Assert.That(dto.Normal!.NowCount, Is.EqualTo(1)); + Assert.That(dto.Normal.Rewards, Has.Count.EqualTo(15)); + Assert.That(dto.Total, Is.Null); + Assert.That(dto.Campaign, Is.Empty); + } + + [Test] + public async Task SecondClaim_same_day_returns_null() + { + var (factory, viewerId) = await SetupAsync(); + using var _ = factory; + + using (var scope1 = factory.Services.CreateScope()) + { + var inv1 = scope1.ServiceProvider.GetRequiredService(); + var bonus1 = scope1.ServiceProvider.GetRequiredService(); + await using var tx1 = await inv1.BeginAsync(viewerId); + await bonus1.GrantIfDueAsync(tx1); + await tx1.CommitAsync(); + } + + using var scope2 = factory.Services.CreateScope(); + var inv2 = scope2.ServiceProvider.GetRequiredService(); + var bonus2 = scope2.ServiceProvider.GetRequiredService(); + await using var tx2 = await inv2.BeginAsync(viewerId); + var dto = await bonus2.GrantIfDueAsync(tx2); + + Assert.That(dto, Is.Null); + } + + [Test] + public async Task Claim_after_rollover_advances_streak() + { + var (factory, viewerId) = await SetupAsync(); + using var _ = factory; + + using (var s1 = factory.Services.CreateScope()) + { + var inv = s1.ServiceProvider.GetRequiredService(); + var bonus = s1.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + await bonus.GrantIfDueAsync(tx); + await tx.CommitAsync(); + } + + // Backdate the claim timestamp by 2 days to simulate a day rollover + using (var s = factory.Services.CreateScope()) + { + var db = s.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.FirstAsync(v => v.Id == viewerId); + v.LastLoginBonusClaimedAt = DateTime.UtcNow.AddDays(-2); + await db.SaveChangesAsync(); + } + + using var s2 = factory.Services.CreateScope(); + var inv2 = s2.ServiceProvider.GetRequiredService(); + var bonus2 = s2.ServiceProvider.GetRequiredService(); + await using var tx2 = await inv2.BeginAsync(viewerId); + var dto = await bonus2.GrantIfDueAsync(tx2); + await tx2.CommitAsync(); + + Assert.That(dto, Is.Not.Null); + Assert.That(dto!.Normal!.NowCount, Is.EqualTo(2)); + var v2 = await ReloadViewerAsync(factory, viewerId); + Assert.That(v2.LoginBonusStreak, Is.EqualTo(2)); + } + + [Test] + public async Task Streak_wraps_after_cycle_length() + { + var (factory, viewerId) = await SetupAsync(); + using var _ = factory; + + // Force the viewer into "just claimed day 15 yesterday" + using (var s = factory.Services.CreateScope()) + { + var db = s.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.FirstAsync(v => v.Id == viewerId); + v.LoginBonusStreak = 15; + v.LastLoginBonusClaimedAt = DateTime.UtcNow.AddDays(-1); + await db.SaveChangesAsync(); + } + + using var s2 = factory.Services.CreateScope(); + var inv = s2.ServiceProvider.GetRequiredService(); + var bonus = s2.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var dto = await bonus.GrantIfDueAsync(tx); + await tx.CommitAsync(); + + Assert.That(dto!.Normal!.NowCount, Is.EqualTo(1)); + var v2 = await ReloadViewerAsync(factory, viewerId); + Assert.That(v2.LoginBonusStreak, Is.EqualTo(1)); + } + + [Test] + public async Task Grant_actually_credits_rupy_for_day1() + { + var (factory, viewerId) = await SetupAsync(); + using var _ = factory; + + long before; + using (var s = factory.Services.CreateScope()) + { + var db = s.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.Include(x => x.Currency).FirstAsync(v => v.Id == viewerId); + before = (long)v.Currency.Rupees; + } + + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + var bonus = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + await bonus.GrantIfDueAsync(tx); + await tx.CommitAsync(); + + using var s2 = factory.Services.CreateScope(); + var db2 = s2.ServiceProvider.GetRequiredService(); + var after = (long)(await db2.Viewers.Include(x => x.Currency).FirstAsync(v => v.Id == viewerId)).Currency.Rupees; + + Assert.That(after - before, Is.EqualTo(20), "Day 1 = 20 Rupy per catalog"); + } + + [Test] + public async Task IsDue_is_false_after_claim_same_day() + { + var (factory, viewerId) = await SetupAsync(); + using var _ = factory; + + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + var bonus = scope.ServiceProvider.GetRequiredService(); + + var v0 = await ReloadViewerAsync(factory, viewerId); + Assert.That(bonus.IsDue(v0), Is.True, "fresh viewer is due"); + + await using var tx = await inv.BeginAsync(viewerId); + await bonus.GrantIfDueAsync(tx); + await tx.CommitAsync(); + + var v1 = await ReloadViewerAsync(factory, viewerId); + Assert.That(bonus.IsDue(v1), Is.False); + } +}