From 7d83560fc9168ec42876656f40c469b51a1b44cb Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 13 Jun 2026 16:40:44 -0400 Subject: [PATCH] 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. --- .../Controllers/LoadController.cs | 10 ++-- .../Controllers/LoadControllerTests.cs | 48 +++++++++++++++++-- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs index 1701efba..536148be 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs @@ -49,12 +49,14 @@ public class LoadController : SVSimController private readonly SVSimDbContext _db; private readonly IInventoryService _inv; private readonly ICardMasterPayloadProvider _cardMaster; + private readonly ILoginBonusService _loginBonus; public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, IGameConfigService config, IBattlePassService battlePass, IViewerMissionStateService missionState, SVSimDbContext db, IInventoryService inv, - ICardMasterPayloadProvider cardMaster) + ICardMasterPayloadProvider cardMaster, + ILoginBonusService loginBonus) { _viewerRepository = viewerRepository; _globalsRepository = globalsRepository; @@ -64,6 +66,7 @@ public class LoadController : SVSimController _db = db; _inv = inv; _cardMaster = cardMaster; + _loginBonus = loginBonus; } [HttpPost("index")] @@ -88,6 +91,7 @@ public class LoadController : SVSimController // the response payload would be one /load/index behind on newly-granted cosmetics. await using var tx = await _inv.BeginAsync(viewer.Id, ct); await tx.BackfillCardCosmeticsAsync(ct); + DailyLoginBonus? loginBonusDto = await _loginBonus.GrantIfDueAsync(tx, ct); await tx.CommitAsync(ct); // Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index. @@ -229,9 +233,7 @@ public class LoadController : SVSimController MaintenanceCards = (await _globalsRepository.GetMaintenanceCards()) .Select(e => e.Id).ToList(), RedEtherOverrides = new List(), - // Optional per spec (load-index.md:247). Populated by ILoginBonusService (Task 6) - // when the viewer has an active bonus period. - DailyLoginBonus = null, + DailyLoginBonus = loginBonusDto, UserRankedMatches = new List(), UserRankInfo = RankFormats.Select(f => new UserRankInfo { diff --git a/SVSim.UnitTests/Controllers/LoadControllerTests.cs b/SVSim.UnitTests/Controllers/LoadControllerTests.cs index 1bef342c..acbb37cc 100644 --- a/SVSim.UnitTests/Controllers/LoadControllerTests.cs +++ b/SVSim.UnitTests/Controllers/LoadControllerTests.cs @@ -307,9 +307,11 @@ public class LoadControllerTests Assert.That(root.GetProperty("rotation_card_set_id_list").GetArrayLength(), Is.GreaterThanOrEqualTo(2)); - // Optional/absent fields stay absent when nothing meaningful to surface - Assert.That(root.TryGetProperty("daily_login_bonus", out _), Is.False, - "daily_login_bonus optional per spec; emit null when no active campaign"); + // daily_login_bonus IS emitted for a fresh viewer (LastLoginBonusClaimedAt is null → + // IsDue returns true). The seeded globals test uses a fresh viewer, so it gets Day 1. + 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. 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] public async Task LoadIndex_emits_battle_pass_level_info_with_100_entries_when_period_active() {