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:
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user