From 61a9133855a42619e9670f0857d36d59c8bc185c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 26 May 2026 22:28:41 -0400 Subject: [PATCH] feat(bp): season + reward importers, idempotent + authoritative-per-season --- .../Importers/BattlePassRewardImporter.cs | 90 +++++++++++++++ .../Importers/BattlePassSeasonImporter.cs | 45 ++++++++ .../Models/Seed/BattlePassRewardSeed.cs | 15 +++ .../Models/Seed/BattlePassSeasonSeed.cs | 16 +++ SVSim.Bootstrap/Program.cs | 2 + .../BattlePassRewardImporterTests.cs | 105 ++++++++++++++++++ .../BattlePassSeasonImporterTests.cs | 85 ++++++++++++++ 7 files changed, 358 insertions(+) create mode 100644 SVSim.Bootstrap/Importers/BattlePassRewardImporter.cs create mode 100644 SVSim.Bootstrap/Importers/BattlePassSeasonImporter.cs create mode 100644 SVSim.Bootstrap/Models/Seed/BattlePassRewardSeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/BattlePassSeasonSeed.cs create mode 100644 SVSim.UnitTests/Importers/BattlePassRewardImporterTests.cs create mode 100644 SVSim.UnitTests/Importers/BattlePassSeasonImporterTests.cs diff --git a/SVSim.Bootstrap/Importers/BattlePassRewardImporter.cs b/SVSim.Bootstrap/Importers/BattlePassRewardImporter.cs new file mode 100644 index 0000000..f689ed7 --- /dev/null +++ b/SVSim.Bootstrap/Importers/BattlePassRewardImporter.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Authoritative upsert of battle-pass rewards from seeds/battle-pass-rewards.json. +/// For each (season_id, track, level) row in the seed: upsert. For rows in the DB that match +/// a seed-mentioned season but are NOT in the seed: DELETE (seed is authoritative per season). +/// Rewards for seasons not mentioned in the seed are left untouched. +/// +public class BattlePassRewardImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + var seed = SeedLoader.LoadList(Path.Combine(seedDir, "battle-pass-rewards.json")); + if (seed.Count == 0) + { + Console.WriteLine("[BattlePassRewardImporter] No seed rows; skipping."); + return 0; + } + + var seededSeasonIds = seed.Select(s => s.SeasonId).Distinct().ToHashSet(); + var dbRows = await context.BattlePassRewards + .Where(r => seededSeasonIds.Contains(r.SeasonId)) + .ToListAsync(); + var dbByKey = dbRows.ToDictionary(r => (r.SeasonId, r.Track, r.Level)); + + int created = 0, updated = 0; + var seenKeys = new HashSet<(int, BattlePassTrack, int)>(); + foreach (var s in seed) + { + var track = ParseTrack(s.Track); + var key = (s.SeasonId, track, s.Level); + seenKeys.Add(key); + if (dbByKey.TryGetValue(key, out var ex)) + { + ex.RewardType = s.RewardType; + ex.RewardDetailId = s.RewardDetailId; + ex.RewardNumber = s.RewardNumber; + ex.IsAppealExclusion = s.IsAppealExclusion; + updated++; + } + else + { + context.BattlePassRewards.Add(new BattlePassRewardEntry + { + Id = MakeId(s.SeasonId, track, s.Level), + SeasonId = s.SeasonId, Track = track, Level = s.Level, + RewardType = s.RewardType, RewardDetailId = s.RewardDetailId, + RewardNumber = s.RewardNumber, IsAppealExclusion = s.IsAppealExclusion, + }); + created++; + } + } + + // Authoritative deletion within seeded seasons. + int deleted = 0; + foreach (var row in dbRows) + { + if (!seenKeys.Contains((row.SeasonId, row.Track, row.Level))) + { + context.BattlePassRewards.Remove(row); + deleted++; + } + } + + await context.SaveChangesAsync(); + Console.WriteLine($"[BattlePassRewardImporter] +{created}/~{updated}/-{deleted}"); + return created + updated; + } + + private static BattlePassTrack ParseTrack(string s) => s.ToLowerInvariant() switch + { + "normal" => BattlePassTrack.Normal, + "premium" => BattlePassTrack.Premium, + _ => throw new InvalidOperationException($"unknown battle pass track: {s}"), + }; + + /// + /// Derives a stable surrogate PK from the (SeasonId, Track, Level) natural key. + /// Encoding: season * 10_000 + track * 1_000 + level. + /// Safe for season < 10_000, track ∈ {0,1}, level < 1_000 — all realistic values. + /// + private static long MakeId(int seasonId, BattlePassTrack track, int level) => + (long)seasonId * 10_000L + (int)track * 1_000 + level; +} diff --git a/SVSim.Bootstrap/Importers/BattlePassSeasonImporter.cs b/SVSim.Bootstrap/Importers/BattlePassSeasonImporter.cs new file mode 100644 index 0000000..0f8b9bc --- /dev/null +++ b/SVSim.Bootstrap/Importers/BattlePassSeasonImporter.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Models; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Idempotent upsert of battle-pass seasons from seeds/battle-pass-seasons.json. +/// Rows missing from the seed are LEFT INTACT (historic seasons). +/// +public class BattlePassSeasonImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + var seed = SeedLoader.LoadList(Path.Combine(seedDir, "battle-pass-seasons.json")); + if (seed.Count == 0) + { + Console.WriteLine("[BattlePassSeasonImporter] No seed rows; skipping."); + return 0; + } + + var existing = await context.BattlePassSeasons.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 BattlePassSeasonEntry { Id = s.Id }; + entry.Name = s.Name; + entry.MaxLevel = s.MaxLevel; + entry.StartDate = DateTimeOffset.Parse(s.StartDate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + entry.EndDate = DateTimeOffset.Parse(s.EndDate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + entry.CanPurchase = s.CanPurchase; + entry.PriceCrystal = s.PriceCrystal; + entry.Description = s.Description; + if (ex is null) { context.BattlePassSeasons.Add(entry); existing[s.Id] = entry; created++; } + else updated++; + } + + await context.SaveChangesAsync(); + Console.WriteLine($"[BattlePassSeasonImporter] +{created}/~{updated}"); + return created + updated; + } +} diff --git a/SVSim.Bootstrap/Models/Seed/BattlePassRewardSeed.cs b/SVSim.Bootstrap/Models/Seed/BattlePassRewardSeed.cs new file mode 100644 index 0000000..fa860c8 --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/BattlePassRewardSeed.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +/// Mirrors a single entry in seeds/battle-pass-rewards.json. +public sealed class BattlePassRewardSeed +{ + [JsonPropertyName("season_id")] public int SeasonId { get; set; } + [JsonPropertyName("track")] public string Track { get; set; } = ""; // "normal" / "premium" + [JsonPropertyName("level")] public int Level { get; set; } + [JsonPropertyName("reward_type")] public int RewardType { get; set; } + [JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; } + [JsonPropertyName("reward_number")] public int RewardNumber { get; set; } + [JsonPropertyName("is_appeal_exclusion")] public bool IsAppealExclusion { get; set; } +} diff --git a/SVSim.Bootstrap/Models/Seed/BattlePassSeasonSeed.cs b/SVSim.Bootstrap/Models/Seed/BattlePassSeasonSeed.cs new file mode 100644 index 0000000..584fbe7 --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/BattlePassSeasonSeed.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +/// Mirrors a single entry in seeds/battle-pass-seasons.json. +public sealed class BattlePassSeasonSeed +{ + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("max_level")] public int MaxLevel { get; set; } + [JsonPropertyName("start_date")] public string StartDate { get; set; } = ""; + [JsonPropertyName("end_date")] public string EndDate { get; set; } = ""; + [JsonPropertyName("can_purchase")] public bool CanPurchase { get; set; } + [JsonPropertyName("price_crystal")] public int PriceCrystal { get; set; } + [JsonPropertyName("description")] public string Description { get; set; } = ""; +} diff --git a/SVSim.Bootstrap/Program.cs b/SVSim.Bootstrap/Program.cs index 46a7617..ff22222 100644 --- a/SVSim.Bootstrap/Program.cs +++ b/SVSim.Bootstrap/Program.cs @@ -85,6 +85,8 @@ public static class Program await new AvatarAbilityImporter().ImportAsync(context, opts.SeedDir); await new ArenaSeasonImporter().ImportAsync(context, opts.SeedDir); await new BattlePassImporter().ImportAsync(context, opts.SeedDir); + await new BattlePassSeasonImporter().ImportAsync(context, opts.SeedDir); + await new BattlePassRewardImporter().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); diff --git a/SVSim.UnitTests/Importers/BattlePassRewardImporterTests.cs b/SVSim.UnitTests/Importers/BattlePassRewardImporterTests.cs new file mode 100644 index 0000000..e6265f2 --- /dev/null +++ b/SVSim.UnitTests/Importers/BattlePassRewardImporterTests.cs @@ -0,0 +1,105 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Bootstrap.Importers; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Importers; + +public class BattlePassRewardImporterTests +{ + private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds"); + + private static async Task SeedSeason23(SVSimDbContext db) + { + db.BattlePassSeasons.Add(new SVSim.Database.Models.BattlePassSeasonEntry + { + Id = 23, Name = "Season 23", MaxLevel = 100, + StartDate = DateTimeOffset.Parse("2026-04-01T02:00:00+09:00"), + EndDate = DateTimeOffset.Parse("2026-07-01T01:59:59+09:00"), + CanPurchase = true, PriceCrystal = 980, Description = "", + }); + await db.SaveChangesAsync(); + } + + [Test] + public async Task Imports_normal_and_premium_tracks() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await SeedSeason23(db); + + await new BattlePassRewardImporter().ImportAsync(db, SeedDir); + + int normal = await db.BattlePassRewards.CountAsync(r => r.SeasonId == 23 && r.Track == BattlePassTrack.Normal); + int premium = await db.BattlePassRewards.CountAsync(r => r.SeasonId == 23 && r.Track == BattlePassTrack.Premium); + Assert.That(normal, Is.EqualTo(44), "captured normal track count for Season 23"); + Assert.That(premium, Is.EqualTo(99), "captured premium track count for Season 23"); + } + + [Test] + public async Task Is_idempotent_on_rerun() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await SeedSeason23(db); + + await new BattlePassRewardImporter().ImportAsync(db, SeedDir); + int before = await db.BattlePassRewards.CountAsync(); + await new BattlePassRewardImporter().ImportAsync(db, SeedDir); + int after = await db.BattlePassRewards.CountAsync(); + + Assert.That(after, Is.EqualTo(before)); + } + + [Test] + public async Task Authoritatively_removes_orphan_rows_for_seeded_season() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await SeedSeason23(db); + db.BattlePassRewards.Add(new SVSim.Database.Models.BattlePassRewardEntry + { + SeasonId = 23, Track = BattlePassTrack.Normal, Level = 99, RewardType = 9, + RewardDetailId = 0, RewardNumber = 9999, IsAppealExclusion = false, + }); + await db.SaveChangesAsync(); + + await new BattlePassRewardImporter().ImportAsync(db, SeedDir); + + bool orphanPresent = await db.BattlePassRewards.AnyAsync( + r => r.SeasonId == 23 && r.Track == BattlePassTrack.Normal && r.Level == 99); + Assert.That(orphanPresent, Is.False, "orphan reward not in seed must be deleted"); + } + + [Test] + public async Task Leaves_other_seasons_untouched() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await SeedSeason23(db); + db.BattlePassSeasons.Add(new SVSim.Database.Models.BattlePassSeasonEntry + { + Id = 22, Name = "Season 22", MaxLevel = 100, + StartDate = DateTimeOffset.Parse("2026-01-01T02:00:00+09:00"), + EndDate = DateTimeOffset.Parse("2026-04-01T01:59:59+09:00"), + CanPurchase = false, PriceCrystal = 980, Description = "", + }); + db.BattlePassRewards.Add(new SVSim.Database.Models.BattlePassRewardEntry + { + SeasonId = 22, Track = BattlePassTrack.Normal, Level = 1, RewardType = 9, + RewardDetailId = 0, RewardNumber = 100, IsAppealExclusion = false, + }); + await db.SaveChangesAsync(); + + await new BattlePassRewardImporter().ImportAsync(db, SeedDir); + + bool s22Preserved = await db.BattlePassRewards.AnyAsync(r => r.SeasonId == 22 && r.Level == 1); + Assert.That(s22Preserved, Is.True, "rewards for unseeded seasons must be left intact"); + } +} diff --git a/SVSim.UnitTests/Importers/BattlePassSeasonImporterTests.cs b/SVSim.UnitTests/Importers/BattlePassSeasonImporterTests.cs new file mode 100644 index 0000000..0ab0f19 --- /dev/null +++ b/SVSim.UnitTests/Importers/BattlePassSeasonImporterTests.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Bootstrap.Importers; +using SVSim.Database; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Importers; + +public class BattlePassSeasonImporterTests +{ + private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds"); + + [Test] + public async Task Imports_season_from_seed_file() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new BattlePassSeasonImporter().ImportAsync(db, SeedDir); + + var season = await db.BattlePassSeasons.SingleAsync(s => s.Id == 23); + Assert.That(season.Name, Does.Contain("Season")); + Assert.That(season.MaxLevel, Is.EqualTo(100)); + Assert.That(season.CanPurchase, Is.True); + Assert.That(season.PriceCrystal, Is.EqualTo(980)); + Assert.That(season.StartDate.Offset, Is.EqualTo(TimeSpan.FromHours(9)), + "JST offset (+09:00) must round-trip through DateTimeOffset"); + } + + [Test] + public async Task Is_idempotent_on_rerun() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new BattlePassSeasonImporter().ImportAsync(db, SeedDir); + int before = await db.BattlePassSeasons.CountAsync(); + await new BattlePassSeasonImporter().ImportAsync(db, SeedDir); + int after = await db.BattlePassSeasons.CountAsync(); + + Assert.That(after, Is.EqualTo(before)); + } + + [Test] + public async Task Updates_existing_row_when_seed_changes() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.BattlePassSeasons.Add(new SVSim.Database.Models.BattlePassSeasonEntry + { + Id = 23, Name = "stale", MaxLevel = 0, PriceCrystal = 0, + StartDate = DateTimeOffset.MinValue, EndDate = DateTimeOffset.MinValue, + }); + await db.SaveChangesAsync(); + + await new BattlePassSeasonImporter().ImportAsync(db, SeedDir); + + var refreshed = await db.BattlePassSeasons.FindAsync(23); + Assert.That(refreshed!.Name, Is.Not.EqualTo("stale")); + Assert.That(refreshed.PriceCrystal, Is.EqualTo(980)); + } + + [Test] + public async Task Empty_seed_is_no_op() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + string tmp = Path.Combine(Path.GetTempPath(), $"seed-{Guid.NewGuid()}"); + Directory.CreateDirectory(tmp); + try + { + File.WriteAllText(Path.Combine(tmp, "battle-pass-seasons.json"), "[]"); + await new BattlePassSeasonImporter().ImportAsync(db, tmp); + int count = await db.BattlePassSeasons.CountAsync(); + Assert.That(count, Is.EqualTo(0)); + } + finally { Directory.Delete(tmp, true); } + } +}