Merge feature/daily-login-bonus: daily login bonus server state
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"bonus_data": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"bonus_data": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"bonus_data": []
|
||||
}
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
4778
SVSim.Database/Migrations/20260613193716_AddViewerLoginBonusState.Designer.cs
generated
Normal file
4778
SVSim.Database/Migrations/20260613193716_AddViewerLoginBonusState.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
52
SVSim.Database/Models/Config/LoginBonusConfig.cs
Normal file
52
SVSim.Database/Models/Config/LoginBonusConfig.cs
Normal 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<T>()</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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
12
SVSim.Database/Models/Config/NormalBonusEntry.cs
Normal file
12
SVSim.Database/Models/Config/NormalBonusEntry.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; } = "[]";
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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>();
|
||||
|
||||
22
SVSim.EmulatedEntrypoint/Services/ILoginBonusService.cs
Normal file
22
SVSim.EmulatedEntrypoint/Services/ILoginBonusService.cs
Normal 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);
|
||||
}
|
||||
68
SVSim.EmulatedEntrypoint/Services/LoginBonusService.cs
Normal file
68
SVSim.EmulatedEntrypoint/Services/LoginBonusService.cs
Normal 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>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
37
SVSim.UnitTests/Config/LoginBonusConfigTests.cs
Normal file
37
SVSim.UnitTests/Config/LoginBonusConfigTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
57
SVSim.UnitTests/Controllers/MyPageControllerTests.cs
Normal file
57
SVSim.UnitTests/Controllers/MyPageControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
59
SVSim.UnitTests/Dtos/DailyLoginBonusWireShapeTests.cs
Normal file
59
SVSim.UnitTests/Dtos/DailyLoginBonusWireShapeTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
190
SVSim.UnitTests/Services/LoginBonusServiceTests.cs
Normal file
190
SVSim.UnitTests/Services/LoginBonusServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user