feat: ILoginBonusService — JST day-bucketed claim + grant + DTO assembly
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.
This commit is contained in:
@@ -113,6 +113,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IArenaTwoPickService, ArenaTwoPickService>();
|
builder.Services.AddScoped<IArenaTwoPickService, ArenaTwoPickService>();
|
||||||
builder.Services.AddScoped<IMatchContextBuilder, MatchContextBuilder>();
|
builder.Services.AddScoped<IMatchContextBuilder, MatchContextBuilder>();
|
||||||
builder.Services.AddScoped<IStoryService, StoryService>();
|
builder.Services.AddScoped<IStoryService, StoryService>();
|
||||||
|
builder.Services.AddScoped<ILoginBonusService, LoginBonusService>();
|
||||||
builder.Services.AddScoped<IDeckListBuilder, DeckListBuilder>();
|
builder.Services.AddScoped<IDeckListBuilder, DeckListBuilder>();
|
||||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||||
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
|
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
|
||||||
|
|||||||
22
SVSim.EmulatedEntrypoint/Services/ILoginBonusService.cs
Normal file
22
SVSim.EmulatedEntrypoint/Services/ILoginBonusService.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using SVSim.Database.Services.Inventory;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILoginBonusService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// If due, advances <c>tx.Viewer.LoginBonusStreak</c> + <c>LastLoginBonusClaimedAt</c>,
|
||||||
|
/// grants the day's reward via <paramref name="tx"/>, and returns the populated wire
|
||||||
|
/// DTO. If not due, returns null and leaves viewer state untouched.
|
||||||
|
/// </summary>
|
||||||
|
Task<DailyLoginBonus?> GrantIfDueAsync(IInventoryTransaction tx, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Read-only "would GrantIfDueAsync emit a bonus right now?" — for MyPage flag.</summary>
|
||||||
|
bool IsDue(SVSim.Database.Models.Viewer viewer);
|
||||||
|
}
|
||||||
68
SVSim.EmulatedEntrypoint/Services/LoginBonusService.cs
Normal file
68
SVSim.EmulatedEntrypoint/Services/LoginBonusService.cs
Normal file
@@ -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<DailyLoginBonus?> GrantIfDueAsync(IInventoryTransaction tx, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var viewer = tx.Viewer;
|
||||||
|
if (!IsDue(viewer)) return null;
|
||||||
|
|
||||||
|
var cfg = _config.Get<LoginBonusConfig>();
|
||||||
|
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<LoginBonusCampaign>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,14 +25,14 @@ public class GameConfigurationJsonbTests
|
|||||||
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
|
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
|
||||||
var byName = rows.ToDictionary(r => r.SectionName);
|
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,
|
// 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[]
|
Assert.That(byName.Keys, Is.EquivalentTo(new[]
|
||||||
{
|
{
|
||||||
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
|
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
|
||||||
"MyRotationSchedule", "Story", "ResourceConfig", "Freeplay", "ArenaTwoPick", "Matching",
|
"MyRotationSchedule", "Story", "ResourceConfig", "Freeplay", "ArenaTwoPick", "Matching",
|
||||||
"CardMasterConfig", "ColosseumSeason", "ColosseumRounds",
|
"CardMasterConfig", "ColosseumSeason", "ColosseumRounds", "LoginBonus",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!;
|
var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!;
|
||||||
|
|||||||
190
SVSim.UnitTests/Services/LoginBonusServiceTests.cs
Normal file
190
SVSim.UnitTests/Services/LoginBonusServiceTests.cs
Normal file
@@ -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<Viewer> ReloadViewerAsync(SVSimTestFactory f, long viewerId)
|
||||||
|
{
|
||||||
|
using var scope = f.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
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<IInventoryService>();
|
||||||
|
var bonus = scope.ServiceProvider.GetRequiredService<ILoginBonusService>();
|
||||||
|
|
||||||
|
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<IInventoryService>();
|
||||||
|
var bonus1 = scope1.ServiceProvider.GetRequiredService<ILoginBonusService>();
|
||||||
|
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<IInventoryService>();
|
||||||
|
var bonus2 = scope2.ServiceProvider.GetRequiredService<ILoginBonusService>();
|
||||||
|
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<IInventoryService>();
|
||||||
|
var bonus = s1.ServiceProvider.GetRequiredService<ILoginBonusService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IInventoryService>();
|
||||||
|
var bonus2 = s2.ServiceProvider.GetRequiredService<ILoginBonusService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IInventoryService>();
|
||||||
|
var bonus = s2.ServiceProvider.GetRequiredService<ILoginBonusService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IInventoryService>();
|
||||||
|
var bonus = scope.ServiceProvider.GetRequiredService<ILoginBonusService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IInventoryService>();
|
||||||
|
var bonus = scope.ServiceProvider.GetRequiredService<ILoginBonusService>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user