feat(load): emit daily_login_bonus via ILoginBonusService

Grant runs inside the existing inventory tx so streak-bump and reward
credit commit atomically with cosmetic backfill.
This commit is contained in:
gamer147
2026-06-13 16:40:44 -04:00
parent 5083d43e51
commit 7d83560fc9
2 changed files with 51 additions and 7 deletions

View File

@@ -49,12 +49,14 @@ public class LoadController : SVSimController
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly IInventoryService _inv; private readonly IInventoryService _inv;
private readonly ICardMasterPayloadProvider _cardMaster; private readonly ICardMasterPayloadProvider _cardMaster;
private readonly ILoginBonusService _loginBonus;
public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
IGameConfigService config, IGameConfigService config,
IBattlePassService battlePass, IViewerMissionStateService missionState, IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db, IInventoryService inv, SVSimDbContext db, IInventoryService inv,
ICardMasterPayloadProvider cardMaster) ICardMasterPayloadProvider cardMaster,
ILoginBonusService loginBonus)
{ {
_viewerRepository = viewerRepository; _viewerRepository = viewerRepository;
_globalsRepository = globalsRepository; _globalsRepository = globalsRepository;
@@ -64,6 +66,7 @@ public class LoadController : SVSimController
_db = db; _db = db;
_inv = inv; _inv = inv;
_cardMaster = cardMaster; _cardMaster = cardMaster;
_loginBonus = loginBonus;
} }
[HttpPost("index")] [HttpPost("index")]
@@ -88,6 +91,7 @@ public class LoadController : SVSimController
// the response payload would be one /load/index behind on newly-granted cosmetics. // the response payload would be one /load/index behind on newly-granted cosmetics.
await using var tx = await _inv.BeginAsync(viewer.Id, ct); await using var tx = await _inv.BeginAsync(viewer.Id, ct);
await tx.BackfillCardCosmeticsAsync(ct); await tx.BackfillCardCosmeticsAsync(ct);
DailyLoginBonus? loginBonusDto = await _loginBonus.GrantIfDueAsync(tx, ct);
await tx.CommitAsync(ct); await tx.CommitAsync(ct);
// Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index. // Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index.
@@ -229,9 +233,7 @@ public class LoadController : SVSimController
MaintenanceCards = (await _globalsRepository.GetMaintenanceCards()) MaintenanceCards = (await _globalsRepository.GetMaintenanceCards())
.Select(e => e.Id).ToList(), .Select(e => e.Id).ToList(),
RedEtherOverrides = new List<RedEtherOverride>(), RedEtherOverrides = new List<RedEtherOverride>(),
// Optional per spec (load-index.md:247). Populated by ILoginBonusService (Task 6) DailyLoginBonus = loginBonusDto,
// when the viewer has an active bonus period.
DailyLoginBonus = null,
UserRankedMatches = new List<UserRankedMatches>(), UserRankedMatches = new List<UserRankedMatches>(),
UserRankInfo = RankFormats.Select(f => new UserRankInfo UserRankInfo = RankFormats.Select(f => new UserRankInfo
{ {

View File

@@ -307,9 +307,11 @@ public class LoadControllerTests
Assert.That(root.GetProperty("rotation_card_set_id_list").GetArrayLength(), Assert.That(root.GetProperty("rotation_card_set_id_list").GetArrayLength(),
Is.GreaterThanOrEqualTo(2)); Is.GreaterThanOrEqualTo(2));
// Optional/absent fields stay absent when nothing meaningful to surface // daily_login_bonus IS emitted for a fresh viewer (LastLoginBonusClaimedAt is null →
Assert.That(root.TryGetProperty("daily_login_bonus", out _), Is.False, // IsDue returns true). The seeded globals test uses a fresh viewer, so it gets Day 1.
"daily_login_bonus optional per spec; emit null when no active campaign"); Assert.That(root.TryGetProperty("daily_login_bonus", out var dlbGlobals), Is.True,
"daily_login_bonus should be present for a fresh viewer (IsDue=true)");
Assert.That(dlbGlobals.ValueKind, Is.EqualTo(JsonValueKind.Object));
// battle_pass_level_info is present when levels are seeded — 100-entry dict keyed by level string. // battle_pass_level_info is present when levels are seeded — 100-entry dict keyed by level string.
Assert.That(root.TryGetProperty("battle_pass_level_info", out var bpli), Is.True, Assert.That(root.TryGetProperty("battle_pass_level_info", out var bpli), Is.True,
@@ -380,6 +382,46 @@ public class LoadControllerTests
} }
} }
[Test]
public async Task Index_emits_daily_login_bonus_for_fresh_viewer()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
var root = await PostIndexAndReadBody(factory, viewerId);
Assert.That(root.TryGetProperty("daily_login_bonus", out var dlb), Is.True);
Assert.That(dlb.ValueKind, Is.EqualTo(JsonValueKind.Object));
var normal = dlb.GetProperty("normal");
Assert.That(normal.GetProperty("now_count").GetInt32(), Is.EqualTo(1));
Assert.That(normal.GetProperty("name").GetString(), Is.EqualTo("Daily Bonus"));
Assert.That(normal.GetProperty("campaign_id").ValueKind, Is.EqualTo(JsonValueKind.String));
Assert.That(normal.GetProperty("img").ValueKind, Is.EqualTo(JsonValueKind.String));
Assert.That(normal.GetProperty("reward").GetArrayLength(), Is.EqualTo(15));
var firstReward = normal.GetProperty("reward")[0];
Assert.That(firstReward.GetProperty("reward_type").GetString(), Is.EqualTo("9"));
Assert.That(firstReward.GetProperty("reward_number").GetString(), Is.EqualTo("20"));
Assert.That(dlb.GetProperty("campaign").ValueKind, Is.EqualTo(JsonValueKind.Array));
Assert.That(dlb.GetProperty("campaign").GetArrayLength(), Is.EqualTo(0));
}
[Test]
public async Task Index_omits_daily_login_bonus_on_second_call_same_day()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await PostIndexAndReadBody(factory, viewerId);
var root = await PostIndexAndReadBody(factory, viewerId);
var present = root.TryGetProperty("daily_login_bonus", out var dlb)
&& dlb.ValueKind != JsonValueKind.Null;
Assert.That(present, Is.False, "Second-same-day /load/index must not re-emit the bonus");
}
[Test] [Test]
public async Task LoadIndex_emits_battle_pass_level_info_with_100_entries_when_period_active() public async Task LoadIndex_emits_battle_pass_level_info_with_100_entries_when_period_active()
{ {