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 AchievementCatalogImporter().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 CardListsImporter().ImportAsync(context, opts.SeedDir);
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");
});
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 =>
{
b.Property<int>("Id")
@@ -2711,6 +2688,12 @@ namespace SVSim.Database.Migrations
b.Property<DateTime>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("LastLoginBonusClaimedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("LoginBonusStreak")
.HasColumnType("integer");
b.Property<int>("MyPageBgId")
.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; }
/// <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>
public int MyPageBgSelectType { get; set; }

View File

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

View File

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

View File

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

View File

@@ -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,11 +233,7 @@ public class LoadController : SVSimController
MaintenanceCards = (await _globalsRepository.GetMaintenanceCards())
.Select(e => e.Id).ToList(),
RedEtherOverrides = new List<RedEtherOverride>(),
// Optional per spec (load-index.md:247). Skeleton-seeded rows in DailyLoginBonuses table
// 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,
DailyLoginBonus = loginBonusDto,
UserRankedMatches = new List<UserRankedMatches>(),
UserRankInfo = RankFormats.Select(f => new UserRankInfo
{

View File

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

View File

@@ -3,16 +3,24 @@ using System.Text.Json.Serialization;
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]
public class DailyLoginBonus
{
[JsonPropertyName("total")]
[Key("total")]
public LoginBonusCampaign? Total { get; set; }
[JsonPropertyName("normal")]
[Key("normal")]
[JsonPropertyName("normal")] [Key("normal")]
public LoginBonusCampaign? Normal { get; set; }
[JsonPropertyName("campaign")]
[Key("campaign")]
public LoginBonusCampaign? Campaign { get; set; }
[JsonPropertyName("total")] [Key("total")]
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;
/// <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]
public class LoginBonusCampaign
{
[JsonPropertyName("name")]
[Key("name")]
[JsonPropertyName("name")] [Key("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("campaign_id")]
[Key("campaign_id")]
public int CampaignId { get; set; }
[JsonPropertyName("img")]
[Key("img")]
public int Image { get; set; }
[JsonPropertyName("now_count")]
[Key("now_count")]
[JsonPropertyName("campaign_id")] [Key("campaign_id")]
public string CampaignId { get; set; } = "0";
[JsonPropertyName("img")] [Key("img")]
public string Img { get; set; } = "0";
[JsonPropertyName("now_count")] [Key("now_count")]
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; }
[JsonPropertyName("reward")]
[Key("reward")]
public List<LoginBonusReward> Rewards { get; set; } = new List<LoginBonusReward>();
[JsonPropertyName("reward")] [Key("reward")]
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;
/// <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]
public class LoginBonusReward
{
/// <summary>
/// The effect shown ingame.
/// </summary>
[JsonPropertyName("effect_id")]
[Key("effect_id")]
public int EffectId { get; set; }
[JsonPropertyName("effect_id")] [Key("effect_id")]
public string EffectId { get; set; } = "0";
/// <summary>
/// The type of reward.
/// </summary>
[JsonPropertyName("reward_type")]
[Key("reward_type")]
public int RewardType { get; set; }
[JsonPropertyName("reward_type")] [Key("reward_type")]
public string RewardType { get; set; } = "0";
/// <summary>
/// A more specified reward type (ie if a pack, which pack).
/// </summary>
[JsonPropertyName("reward_detail_id")]
[Key("reward_detail_id")]
public int RewardDetailId { get; set; }
[JsonPropertyName("reward_detail_id")] [Key("reward_detail_id")]
public string RewardDetailId { get; set; } = "0";
/// <summary>
/// The count of the reward.
/// </summary>
[JsonPropertyName("reward_number")]
[Key("reward_number")]
public int RewardNumber { get; set; }
[JsonPropertyName("reward_number")] [Key("reward_number")]
public string RewardNumber { get; set; } = "0";
}

View File

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

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

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

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>
/// 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
/// 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).
@@ -73,19 +73,6 @@ public class LoadIndexImporterTests
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]
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 BattlePassSeasonImporter().ImportAsync(ctx, seedDir);
await new BattlePassRewardImporter().ImportAsync(ctx, seedDir);
await new DailyLoginBonusImporter().ImportAsync(ctx, seedDir);
await new PreReleaseInfoImporter().ImportAsync(ctx, seedDir);
await new CardListsImporter().ImportAsync(ctx, seedDir);
await new RotationFlagUpdater().UpdateAsync(ctx);

View File

@@ -25,14 +25,14 @@ public class GameConfigurationJsonbTests
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
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,
// Freeplay, ArenaTwoPick, Matching, CardMasterConfig, ColosseumSeason, ColosseumRounds).
// Freeplay, ArenaTwoPick, Matching, CardMasterConfig, ColosseumSeason, ColosseumRounds, LoginBonus).
Assert.That(byName.Keys, Is.EquivalentTo(new[]
{
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
"MyRotationSchedule", "Story", "ResourceConfig", "Freeplay", "ArenaTwoPick", "Matching",
"CardMasterConfig", "ColosseumSeason", "ColosseumRounds",
"CardMasterConfig", "ColosseumSeason", "ColosseumRounds", "LoginBonus",
}));
var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!;

View File

@@ -80,16 +80,6 @@ public class GlobalsRepositoryTests
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]
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);
}
}