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