Merge feature/daily-login-bonus: daily login bonus server state

This commit is contained in:
gamer147
2026-06-13 17:26:20 -04:00
32 changed files with 5479 additions and 203 deletions

View File

@@ -1,14 +0,0 @@
[
{
"id": 1,
"bonus_data": []
},
{
"id": 3,
"bonus_data": []
},
{
"id": 4,
"bonus_data": []
}
]

View File

@@ -1,37 +0,0 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of daily-login-bonus campaign rows from <c>seeds/daily-login-bonus.json</c>.
/// <c>bonus_data</c> array preserved verbatim — prod observed empty arrays outside active events.
/// </summary>
public class DailyLoginBonusImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<DailyLoginBonusSeed>(Path.Combine(seedDir, "daily-login-bonus.json"));
if (seed.Count == 0) return 0;
var existing = await context.DailyLoginBonuses.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.Id == 0) continue;
var entry = existing.TryGetValue(s.Id, out var ex) ? ex : new DailyLoginBonusEntry { Id = s.Id };
entry.BonusData = s.BonusData.ValueKind == JsonValueKind.Undefined
? "[]"
: JsonSerializer.Serialize(s.BonusData);
if (ex is null) { context.DailyLoginBonuses.Add(entry); existing[s.Id] = entry; created++; }
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[DailyLoginBonusImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -1,11 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
/// <summary>Mirrors <c>seeds/daily-login-bonus.json</c>. <c>bonus_data</c> preserved verbatim.</summary>
public sealed class DailyLoginBonusSeed
{
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("bonus_data")] public JsonElement BonusData { get; set; }
}

View File

@@ -94,7 +94,6 @@ public static class Program
await new MissionCatalogImporter().ImportAsync(context, opts.SeedDir); await new MissionCatalogImporter().ImportAsync(context, opts.SeedDir);
await new AchievementCatalogImporter().ImportAsync(context, opts.SeedDir); await new AchievementCatalogImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassMonthlyMissionImporter().ImportAsync(context, opts.SeedDir); await new BattlePassMonthlyMissionImporter().ImportAsync(context, opts.SeedDir);
await new DailyLoginBonusImporter().ImportAsync(context, opts.SeedDir);
await new PreReleaseInfoImporter().ImportAsync(context, opts.SeedDir); await new PreReleaseInfoImporter().ImportAsync(context, opts.SeedDir);
await new CardListsImporter().ImportAsync(context, opts.SeedDir); await new CardListsImporter().ImportAsync(context, opts.SeedDir);
await new RotationFlagUpdater().UpdateAsync(context); await new RotationFlagUpdater().UpdateAsync(context);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddViewerLoginBonusState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DailyLoginBonuses");
migrationBuilder.AddColumn<DateTime>(
name: "LastLoginBonusClaimedAt",
table: "Viewers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LoginBonusStreak",
table: "Viewers",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastLoginBonusClaimedAt",
table: "Viewers");
migrationBuilder.DropColumn(
name: "LoginBonusStreak",
table: "Viewers");
migrationBuilder.CreateTable(
name: "DailyLoginBonuses",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
BonusData = table.Column<string>(type: "jsonb", nullable: false),
BonusId = table.Column<int>(type: "integer", nullable: false),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DailyLoginBonuses", x => x.Id);
});
}
}
}

View File

@@ -1075,29 +1075,6 @@ namespace SVSim.Database.Migrations
b.ToTable("ColosseumWindFallDecks"); b.ToTable("ColosseumWindFallDecks");
}); });
modelBuilder.Entity("SVSim.Database.Models.DailyLoginBonusEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<string>("BonusData")
.IsRequired()
.HasColumnType("jsonb");
b.Property<int>("BonusId")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("DailyLoginBonuses");
});
modelBuilder.Entity("SVSim.Database.Models.DefaultDeckEntry", b => modelBuilder.Entity("SVSim.Database.Models.DefaultDeckEntry", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -2711,6 +2688,12 @@ namespace SVSim.Database.Migrations
b.Property<DateTime>("LastLogin") b.Property<DateTime>("LastLogin")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<DateTime?>("LastLoginBonusClaimedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("LoginBonusStreak")
.HasColumnType("integer");
b.Property<int>("MyPageBgId") b.Property<int>("MyPageBgId")
.HasColumnType("integer"); .HasColumnType("integer");

View File

@@ -0,0 +1,52 @@
using SVSim.Database.Enums;
namespace SVSim.Database.Models.Config;
/// <summary>
/// Daily login bonus catalog. The Normal cycle is the always-on N-day streak. Total
/// (continuous milestones) and Campaign (date-bounded specials) are placeholders — wire
/// emits empty arrays for them until a campaign is captured and seeded here.
/// </summary>
[ConfigSection("LoginBonus")]
public class LoginBonusConfig
{
public int CampaignId { get; set; } = 3;
public string Name { get; set; } = "Daily Bonus";
/// <summary>BG image asset key — wire ships as string. Prod captured value is "0".</summary>
public string Img { get; set; } = "0";
/// <summary>
/// Normal-cycle reward entries, 1-based by Day. Defaults to <c>new()</c> (empty);
/// real entries live in <see cref="ShippedDefaults"/>. <b>Do not move them into this
/// property initializer</b> — collection initializers silently empty out under
/// <c>GameConfigService.Get&lt;T&gt;()</c>'s tier-merge (config-defaults convention,
/// historical bug 2026-05-24).
/// </summary>
public List<NormalBonusEntry> Normal { get; set; } = new();
public static LoginBonusConfig ShippedDefaults() => new()
{
CampaignId = 3,
Name = "Daily Bonus",
Img = "0",
Normal = new List<NormalBonusEntry>
{
new() { Day = 1, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 20 },
new() { Day = 2, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 20 },
new() { Day = 3, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 20 },
new() { Day = 4, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 20 },
new() { Day = 5, EffectId = 2, RewardType = UserGoodsType.Item, RewardDetailId = 80001, RewardNumber = 1 },
new() { Day = 6, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 30 },
new() { Day = 7, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 30 },
new() { Day = 8, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 30 },
new() { Day = 9, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 30 },
new() { Day = 10, EffectId = 2, RewardType = UserGoodsType.Item, RewardDetailId = 80001, RewardNumber = 1 },
new() { Day = 11, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 40 },
new() { Day = 12, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 40 },
new() { Day = 13, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 40 },
new() { Day = 14, EffectId = 1, RewardType = UserGoodsType.Rupy, RewardDetailId = 0, RewardNumber = 40 },
new() { Day = 15, EffectId = 2, RewardType = UserGoodsType.Item, RewardDetailId = 1, RewardNumber = 1 },
},
};
}

View File

@@ -0,0 +1,12 @@
using SVSim.Database.Enums;
namespace SVSim.Database.Models.Config;
public class NormalBonusEntry
{
public int Day { get; set; }
public int EffectId { get; set; } = 1;
public UserGoodsType RewardType { get; set; }
public long RewardDetailId { get; set; }
public int RewardNumber { get; set; }
}

View File

@@ -1,17 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// Daily login bonus campaign from /load/index data.daily_login_bonus (dict keyed by bonus_id,
/// values are arrays of bonus days). Prod observed keys {1, 3, 4} with empty arrays — recapture
/// target during active login bonus events.
/// </summary>
public class DailyLoginBonusEntry : BaseEntity<int>
{
public int BonusId { get => Id; set => Id = value; }
[Column(TypeName = "jsonb")]
public string BonusData { get; set; } = "[]";
}

View File

@@ -33,6 +33,18 @@ public class Viewer : BaseEntity<long>
public DateTime LastLogin { get; set; } public DateTime LastLogin { get; set; }
/// <summary>
/// UTC instant of the most recent daily-login-bonus claim. Null = never claimed.
/// Compared against JST 02:00 day boundary via JstPeriod.DayKey to decide eligibility.
/// </summary>
public DateTime? LastLoginBonusClaimedAt { get; set; }
/// <summary>
/// 1-based position in the Normal login-bonus cycle. 0 = never claimed (first claim
/// will write 1). Wraps back to 1 after reaching cycle length (LoginBonusConfig.Normal.Count).
/// </summary>
public int LoginBonusStreak { get; set; }
/// <summary>BGType enum: 0=Deck, 1=CustomBG, 2=RandomBG. Default 0 = follow equipped deck's leader skin.</summary> /// <summary>BGType enum: 0=Deck, 1=CustomBG, 2=RandomBG. Default 0 = follow equipped deck's leader skin.</summary>
public int MyPageBgSelectType { get; set; } public int MyPageBgSelectType { get; set; }

View File

@@ -59,9 +59,6 @@ public class GlobalsRepository : IGlobalsRepository
public Task<List<BattlePassLevelEntry>> GetBattlePassLevels() => public Task<List<BattlePassLevelEntry>> GetBattlePassLevels() =>
_dbContext.BattlePassLevels.AsNoTracking().ToListAsync(); _dbContext.BattlePassLevels.AsNoTracking().ToListAsync();
public Task<List<DailyLoginBonusEntry>> GetDailyLoginBonus() =>
_dbContext.DailyLoginBonuses.AsNoTracking().ToListAsync();
public Task<List<BannerEntry>> GetBanners() => public Task<List<BannerEntry>> GetBanners() =>
_dbContext.Banners.AsNoTracking().OrderBy(b => b.Id).ToListAsync(); _dbContext.Banners.AsNoTracking().OrderBy(b => b.Id).ToListAsync();

View File

@@ -19,7 +19,6 @@ public interface IGlobalsRepository
Task<List<UnlimitedRestrictionEntry>> GetUnlimitedRestrictions(); Task<List<UnlimitedRestrictionEntry>> GetUnlimitedRestrictions();
Task<List<LoadingExclusionCardEntry>> GetLoadingExclusionCards(); Task<List<LoadingExclusionCardEntry>> GetLoadingExclusionCards();
Task<List<BattlePassLevelEntry>> GetBattlePassLevels(); Task<List<BattlePassLevelEntry>> GetBattlePassLevels();
Task<List<DailyLoginBonusEntry>> GetDailyLoginBonus();
Task<List<BannerEntry>> GetBanners(); Task<List<BannerEntry>> GetBanners();
Task<IReadOnlyList<HomeDialogEntry>> GetActiveHomeDialogsAsync(DateTime nowUtc); Task<IReadOnlyList<HomeDialogEntry>> GetActiveHomeDialogsAsync(DateTime nowUtc);
Task<SealedConfig?> GetCurrentSealedSeason(); Task<SealedConfig?> GetCurrentSealedSeason();

View File

@@ -60,7 +60,6 @@ public class SVSimDbContext : DbContext
public DbSet<ViewerMission> ViewerMissions => Set<ViewerMission>(); public DbSet<ViewerMission> ViewerMissions => Set<ViewerMission>();
public DbSet<ViewerAchievement> ViewerAchievements => Set<ViewerAchievement>(); public DbSet<ViewerAchievement> ViewerAchievements => Set<ViewerAchievement>();
public DbSet<ViewerEventCounter> ViewerEventCounters => Set<ViewerEventCounter>(); public DbSet<ViewerEventCounter> ViewerEventCounters => Set<ViewerEventCounter>();
public DbSet<DailyLoginBonusEntry> DailyLoginBonuses => Set<DailyLoginBonusEntry>();
public DbSet<BannerEntry> Banners => Set<BannerEntry>(); public DbSet<BannerEntry> Banners => Set<BannerEntry>();
public DbSet<HomeDialogEntry> HomeDialogEntries => Set<HomeDialogEntry>(); public DbSet<HomeDialogEntry> HomeDialogEntries => Set<HomeDialogEntry>();
public DbSet<SealedConfig> SealedSeasons => Set<SealedConfig>(); public DbSet<SealedConfig> SealedSeasons => Set<SealedConfig>();

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,11 +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). Skeleton-seeded rows in DailyLoginBonuses table DailyLoginBonus = loginBonusDto,
// capture prod's empty-period shape ({"1":[], "3":[], "4":[]}); the spec-shaped DTO
// ({normal?, total?, campaign?[]}) carries nothing meaningful until an active campaign
// is captured.
DailyLoginBonus = null,
UserRankedMatches = new List<UserRankedMatches>(), UserRankedMatches = new List<UserRankedMatches>(),
UserRankInfo = RankFormats.Select(f => new UserRankInfo UserRankInfo = RankFormats.Select(f => new UserRankInfo
{ {

View File

@@ -30,16 +30,18 @@ public class MyPageController : SVSimController
private readonly IGameConfigService _config; private readonly IGameConfigService _config;
private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns; private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns;
private readonly IHomeDialogSessionTracker _homeDialogTracker; private readonly IHomeDialogSessionTracker _homeDialogTracker;
private readonly ILoginBonusService _loginBonus;
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns, IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns,
IHomeDialogSessionTracker homeDialogTracker) IHomeDialogSessionTracker homeDialogTracker, ILoginBonusService loginBonus)
{ {
_viewerRepository = viewerRepository; _viewerRepository = viewerRepository;
_globalsRepository = globalsRepository; _globalsRepository = globalsRepository;
_config = config; _config = config;
_arenaTwoPickRuns = arenaTwoPickRuns; _arenaTwoPickRuns = arenaTwoPickRuns;
_homeDialogTracker = homeDialogTracker; _homeDialogTracker = homeDialogTracker;
_loginBonus = loginBonus;
} }
[HttpPost("index")] [HttpPost("index")]
@@ -83,6 +85,7 @@ public class MyPageController : SVSimController
return new MyPageIndexResponse return new MyPageIndexResponse
{ {
UserInfo = new UserInfo(deviceType, viewer), UserInfo = new UserInfo(deviceType, viewer),
CanGiveDailyLoginBonus = _loginBonus.IsDue(viewer),
UnreceivedMissionRewardCount = 0, // TODO(mypage-stub): viewer mission progress UnreceivedMissionRewardCount = 0, // TODO(mypage-stub): viewer mission progress
ReceiveFriendApplyCount = 0, // TODO(mypage-stub): viewer friend-request inbox ReceiveFriendApplyCount = 0, // TODO(mypage-stub): viewer friend-request inbox
UnreadPresentCount = 0, // TODO(mypage-stub): viewer presents/mail UnreadPresentCount = 0, // TODO(mypage-stub): viewer presents/mail

View File

@@ -3,16 +3,24 @@ using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos; namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// Wire shape: { normal?, total?, campaign? (array) }. Client parser at
/// LoadDetail.cs:553 reads exactly these three keys. <c>normal</c> presence drives
/// IsExistLoginBonusData() / popup eligibility.
/// </summary>
[MessagePackObject] [MessagePackObject]
public class DailyLoginBonus public class DailyLoginBonus
{ {
[JsonPropertyName("total")] [JsonPropertyName("normal")] [Key("normal")]
[Key("total")]
public LoginBonusCampaign? Total { get; set; }
[JsonPropertyName("normal")]
[Key("normal")]
public LoginBonusCampaign? Normal { get; set; } public LoginBonusCampaign? Normal { get; set; }
[JsonPropertyName("campaign")]
[Key("campaign")] [JsonPropertyName("total")] [Key("total")]
public LoginBonusCampaign? Campaign { get; set; } public LoginBonusCampaign? Total { get; set; }
/// <summary>
/// Array of campaign panels (LoadDetail.cs:583 iterates jsonData3[i]). Empty list
/// when no specials are active — prod sends [] not null in that case.
/// </summary>
[JsonPropertyName("campaign")] [Key("campaign")]
public List<LoginBonusCampaign> Campaign { get; set; } = new();
} }

View File

@@ -3,25 +3,39 @@ using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos; namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// One login-bonus campaign panel. Used for normal, each campaign array entry, and (when
/// populated) total. <c>campaign_id</c> and <c>img</c> ship as strings on the wire. Client
/// consumers: NormalData (reads name, now_count, is_next_reward, reward[]),
/// SpecialData (also reads img, is_one_day_multi_rewards — start_date/end_date are not
/// modeled yet and ship absent until a real campaign capture lands),
/// ContinuousData (reads only reward[]).
/// </summary>
[MessagePackObject] [MessagePackObject]
public class LoginBonusCampaign public class LoginBonusCampaign
{ {
[JsonPropertyName("name")] [JsonPropertyName("name")] [Key("name")]
[Key("name")]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
[JsonPropertyName("campaign_id")]
[Key("campaign_id")] [JsonPropertyName("campaign_id")] [Key("campaign_id")]
public int CampaignId { get; set; } public string CampaignId { get; set; } = "0";
[JsonPropertyName("img")]
[Key("img")] [JsonPropertyName("img")] [Key("img")]
public int Image { get; set; } public string Img { get; set; } = "0";
[JsonPropertyName("now_count")]
[Key("now_count")] [JsonPropertyName("now_count")] [Key("now_count")]
public int NowCount { get; set; } public int NowCount { get; set; }
[JsonPropertyName("is_next_reward")]
[Key("is_next_reward")] [JsonPropertyName("is_next_reward")] [Key("is_next_reward")]
public bool IsNextReward { get; set; } public bool IsNextReward { get; set; }
[JsonPropertyName("reward")]
[Key("reward")] [JsonPropertyName("reward")] [Key("reward")]
public List<LoginBonusReward> Rewards { get; set; } = new List<LoginBonusReward>(); public List<LoginBonusReward> Rewards { get; set; } = new();
/// <summary>
/// SpecialData-only on the client (SpecialData.cs:50). Server emits it on every panel
/// for byte-faithful replay; client ignores it on Normal/Total paths.
/// </summary>
[JsonPropertyName("is_one_day_multi_rewards")] [Key("is_one_day_multi_rewards")]
public bool IsOneDayMultiRewards { get; set; }
} }

View File

@@ -3,34 +3,23 @@ using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos; namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// One day's reward in a login-bonus cycle. All numeric fields ship as quoted strings on
/// the wire — prod-confirmed shape from traffic_prod_tutorial.ndjson. Client uses
/// JsonData.ToInt() so it tolerates either form, but we match prod exactly.
/// </summary>
[MessagePackObject] [MessagePackObject]
public class LoginBonusReward public class LoginBonusReward
{ {
/// <summary> [JsonPropertyName("effect_id")] [Key("effect_id")]
/// The effect shown ingame. public string EffectId { get; set; } = "0";
/// </summary>
[JsonPropertyName("effect_id")]
[Key("effect_id")]
public int EffectId { get; set; }
/// <summary> [JsonPropertyName("reward_type")] [Key("reward_type")]
/// The type of reward. public string RewardType { get; set; } = "0";
/// </summary>
[JsonPropertyName("reward_type")]
[Key("reward_type")]
public int RewardType { get; set; }
/// <summary> [JsonPropertyName("reward_detail_id")] [Key("reward_detail_id")]
/// A more specified reward type (ie if a pack, which pack). public string RewardDetailId { get; set; } = "0";
/// </summary>
[JsonPropertyName("reward_detail_id")]
[Key("reward_detail_id")]
public int RewardDetailId { get; set; }
/// <summary> [JsonPropertyName("reward_number")] [Key("reward_number")]
/// The count of the reward. public string RewardNumber { get; set; } = "0";
/// </summary>
[JsonPropertyName("reward_number")]
[Key("reward_number")]
public int RewardNumber { get; set; }
} }

View File

@@ -149,10 +149,10 @@ public class IndexResponse
public List<UserRankedMatches> UserRankedMatches { get; set; } = new(); public List<UserRankedMatches> UserRankedMatches { get; set; } = new();
/// <summary> /// <summary>
/// Spec: optional. Shape is {normal?, total?, campaign?[]} per common/types.ts.md DailyLoginBonus. /// Spec: optional. Shape {normal?, total?, campaign?[]} — client parses three keys
/// Until we have an active login-bonus campaign to surface in spec shape, omit. The skeleton /// in LoadDetail.cs:553. <c>normal</c> presence triggers the login-bonus popup
/// rows in DailyLoginBonuses table (prod sent {"1":[], "3":[], "4":[]}) preserve the capture /// (NextSceneSwitcher.cs:103). Populated by ILoginBonusService at /load/index time;
/// for archive but don't make it into the wire response. /// null when the viewer has already claimed today's bonus.
/// </summary> /// </summary>
[JsonPropertyName("daily_login_bonus")] [JsonPropertyName("daily_login_bonus")]
[Key("daily_login_bonus")] [Key("daily_login_bonus")]

View File

@@ -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>();

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

View File

@@ -0,0 +1,37 @@
using NUnit.Framework;
using SVSim.Database.Enums;
using SVSim.Database.Models.Config;
namespace SVSim.UnitTests.Config;
public class LoginBonusConfigTests
{
[Test]
public void ShippedDefaults_matches_prod_normal_cycle()
{
var cfg = LoginBonusConfig.ShippedDefaults();
Assert.That(cfg.CampaignId, Is.EqualTo(3));
Assert.That(cfg.Name, Is.EqualTo("Daily Bonus"));
Assert.That(cfg.Img, Is.EqualTo("0"));
Assert.That(cfg.Normal, Has.Count.EqualTo(15));
// Day 1: 20 Rupy
Assert.That(cfg.Normal[0].Day, Is.EqualTo(1));
Assert.That(cfg.Normal[0].RewardType, Is.EqualTo(UserGoodsType.Rupy));
Assert.That(cfg.Normal[0].RewardDetailId, Is.EqualTo(0));
Assert.That(cfg.Normal[0].RewardNumber, Is.EqualTo(20));
Assert.That(cfg.Normal[0].EffectId, Is.EqualTo(1));
// Day 5: Item 80001 ×1 with effect 2 (the highlight ticket)
Assert.That(cfg.Normal[4].RewardType, Is.EqualTo(UserGoodsType.Item));
Assert.That(cfg.Normal[4].RewardDetailId, Is.EqualTo(80001));
Assert.That(cfg.Normal[4].RewardNumber, Is.EqualTo(1));
Assert.That(cfg.Normal[4].EffectId, Is.EqualTo(2));
// Day 15: Item id 1 ×1 with effect 2 (cycle capstone)
Assert.That(cfg.Normal[14].RewardType, Is.EqualTo(UserGoodsType.Item));
Assert.That(cfg.Normal[14].RewardDetailId, Is.EqualTo(1));
Assert.That(cfg.Normal[14].RewardNumber, Is.EqualTo(1));
}
}

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()
{ {

View File

@@ -0,0 +1,57 @@
using System.Net;
using System.Text;
using System.Text.Json;
using NUnit.Framework;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Coverage for <c>/mypage/index</c>. Focused on fields computed from viewer state
/// that are easy to regress (can_give_daily_login_bonus).
/// </summary>
public class MyPageControllerTests
{
// MyPageIndexRequest extends BaseRequest, so viewer_id + steam_session_ticket are required.
private const string MyPageRequestJson =
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"steam"}""";
private const string LoadIndexRequestJson =
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"steam","card_master_hash":""}""";
[Test]
public async Task MyPage_can_give_daily_login_bonus_is_true_for_fresh_viewer()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var resp = await client.PostAsync("/mypage/index",
new StringContent(MyPageRequestJson, Encoding.UTF8, "application/json"));
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await resp.Content.ReadAsStringAsync());
var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.That(root.GetProperty("can_give_daily_login_bonus").GetBoolean(), Is.True);
}
[Test]
public async Task MyPage_can_give_daily_login_bonus_is_false_after_load_index_claim()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
// /load/index claims today's bonus
var loadResp = await client.PostAsync("/load/index",
new StringContent(LoadIndexRequestJson, Encoding.UTF8, "application/json"));
Assert.That(loadResp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await loadResp.Content.ReadAsStringAsync());
// /mypage/index must report flag = false after the claim
var resp = await client.PostAsync("/mypage/index",
new StringContent(MyPageRequestJson, Encoding.UTF8, "application/json"));
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await resp.Content.ReadAsStringAsync());
var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.That(root.GetProperty("can_give_daily_login_bonus").GetBoolean(), Is.False);
}
}

View File

@@ -0,0 +1,59 @@
using System.Text.Json;
using NUnit.Framework;
using SVSim.EmulatedEntrypoint.Models.Dtos;
namespace SVSim.UnitTests.Dtos;
public class DailyLoginBonusWireShapeTests
{
private static readonly JsonSerializerOptions Opts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
[Test]
public void Reward_numeric_fields_serialize_as_strings()
{
var r = new LoginBonusReward
{
EffectId = "1", RewardType = "9", RewardDetailId = "0", RewardNumber = "20"
};
var json = JsonSerializer.Serialize(r, Opts);
using var doc = JsonDocument.Parse(json);
Assert.That(doc.RootElement.GetProperty("effect_id").ValueKind, Is.EqualTo(JsonValueKind.String));
Assert.That(doc.RootElement.GetProperty("reward_type").ValueKind, Is.EqualTo(JsonValueKind.String));
Assert.That(doc.RootElement.GetProperty("reward_detail_id").ValueKind, Is.EqualTo(JsonValueKind.String));
Assert.That(doc.RootElement.GetProperty("reward_number").ValueKind, Is.EqualTo(JsonValueKind.String));
}
[Test]
public void Campaign_id_and_img_serialize_as_strings()
{
var c = new LoginBonusCampaign { CampaignId = "3", Img = "0", Name = "Daily Bonus" };
var json = JsonSerializer.Serialize(c, Opts);
using var doc = JsonDocument.Parse(json);
Assert.That(doc.RootElement.GetProperty("campaign_id").ValueKind, Is.EqualTo(JsonValueKind.String));
Assert.That(doc.RootElement.GetProperty("img").ValueKind, Is.EqualTo(JsonValueKind.String));
}
[Test]
public void Campaign_supports_is_one_day_multi_rewards_flag()
{
var c = new LoginBonusCampaign { IsOneDayMultiRewards = false };
var json = JsonSerializer.Serialize(c, Opts);
using var doc = JsonDocument.Parse(json);
Assert.That(doc.RootElement.TryGetProperty("is_one_day_multi_rewards", out var v), Is.True);
Assert.That(v.ValueKind, Is.EqualTo(JsonValueKind.False));
}
[Test]
public void DailyLoginBonus_campaign_is_an_array()
{
var d = new DailyLoginBonus { Normal = new LoginBonusCampaign(), Campaign = new List<LoginBonusCampaign>() };
var json = JsonSerializer.Serialize(d, Opts);
using var doc = JsonDocument.Parse(json);
Assert.That(doc.RootElement.GetProperty("campaign").ValueKind, Is.EqualTo(JsonValueKind.Array));
}
}

View File

@@ -8,7 +8,7 @@ namespace SVSim.UnitTests.Importers;
/// <summary> /// <summary>
/// Happy-path coverage for the load-index importer classes introduced in Stage 9B /// Happy-path coverage for the load-index importer classes introduced in Stage 9B
/// (RotationConfig, MyRotation, AvatarAbility, ArenaSeason, DailyLoginBonus, /// (RotationConfig, MyRotation, AvatarAbility, ArenaSeason,
/// PreReleaseInfo). Each test instantiates the importer in isolation and verifies it inserts /// PreReleaseInfo). Each test instantiates the importer in isolation and verifies it inserts
/// rows from the corresponding seed file under <c>Data/seeds/</c>. /// rows from the corresponding seed file under <c>Data/seeds/</c>.
/// Idempotency, edge cases, and per-importer detail tests live in dedicated *ImporterTests files (e.g. BattlePassImporterTests). /// Idempotency, edge cases, and per-importer detail tests live in dedicated *ImporterTests files (e.g. BattlePassImporterTests).
@@ -73,19 +73,6 @@ public class LoadIndexImporterTests
Assert.That(row!.FormatInfo, Is.Not.EqualTo("{}"), "format_info blob must be populated from seed"); Assert.That(row!.FormatInfo, Is.Not.EqualTo("{}"), "format_info blob must be populated from seed");
} }
[Test]
public async Task DailyLoginBonusImporter_writes_bonus_rows()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new DailyLoginBonusImporter().ImportAsync(db, SeedDir);
Assert.That(await db.DailyLoginBonuses.CountAsync(), Is.GreaterThan(0),
"daily-login-bonus.json must produce rows");
}
[Test] [Test]
public async Task PreReleaseInfoImporter_writes_singleton() public async Task PreReleaseInfoImporter_writes_singleton()
{ {

View File

@@ -266,7 +266,6 @@ internal class SVSimTestFactory : WebApplicationFactory<Program>
await new BattlePassImporter().ImportAsync(ctx, seedDir); await new BattlePassImporter().ImportAsync(ctx, seedDir);
await new BattlePassSeasonImporter().ImportAsync(ctx, seedDir); await new BattlePassSeasonImporter().ImportAsync(ctx, seedDir);
await new BattlePassRewardImporter().ImportAsync(ctx, seedDir); await new BattlePassRewardImporter().ImportAsync(ctx, seedDir);
await new DailyLoginBonusImporter().ImportAsync(ctx, seedDir);
await new PreReleaseInfoImporter().ImportAsync(ctx, seedDir); await new PreReleaseInfoImporter().ImportAsync(ctx, seedDir);
await new CardListsImporter().ImportAsync(ctx, seedDir); await new CardListsImporter().ImportAsync(ctx, seedDir);
await new RotationFlagUpdater().UpdateAsync(ctx); await new RotationFlagUpdater().UpdateAsync(ctx);

View File

@@ -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)!;

View File

@@ -80,16 +80,6 @@ public class GlobalsRepositoryTests
Assert.That(levels.Count, Is.EqualTo(100)); Assert.That(levels.Count, Is.EqualTo(100));
} }
[Test]
public async Task GetDailyLoginBonus_returns_three_skeleton_entries()
{
var (factory, repo) = await SetupAsync();
using var _ = factory;
var bonuses = await repo.GetDailyLoginBonus();
Assert.That(bonuses.Count, Is.EqualTo(3),
"Prod capture has keys {1, 3, 4} with empty arrays — skeleton presence still seeded.");
}
[Test] [Test]
public async Task GetPreReleaseInfo_returns_singleton() public async Task GetPreReleaseInfo_returns_singleton()
{ {

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