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:
gamer147
2026-06-13 16:15:21 -04:00
parent b831810fc1
commit 5083d43e51
5 changed files with 284 additions and 3 deletions

View File

@@ -113,6 +113,7 @@ public class Program
builder.Services.AddScoped<IArenaTwoPickService, ArenaTwoPickService>();
builder.Services.AddScoped<IMatchContextBuilder, MatchContextBuilder>();
builder.Services.AddScoped<IStoryService, StoryService>();
builder.Services.AddScoped<ILoginBonusService, LoginBonusService>();
builder.Services.AddScoped<IDeckListBuilder, DeckListBuilder>();
builder.Services.AddSingleton<IRandom, SystemRandom>();
builder.Services.AddSingleton<PuzzleMissionEvaluator>();

View 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);
}

View 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>(),
};
}
}