From 87d0001569ebeb108fea3fd5672328bb89dd0f0a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 26 May 2026 15:29:57 -0400 Subject: [PATCH] refactor(bootstrap): add 7 load-index importers (excluding card lists) Stage 9B of the bootstrap-seed-refactor: add per-domain importer classes that consume the load-index seed split produced in Stage 9A. New importers (each in its own file under SVSim.Bootstrap/Importers/): - RotationConfigImporter: writes Rotation/Challenge/MyRotationSchedule GameConfig sections (atomic UpsertSection pattern, copied private-static from GlobalsImporter so this importer stands alone post-9C). - MyRotationImporter: settings + abilities (extractor pre-joins on rotation_id). - AvatarAbilityImporter: per-leader_skin_id ability rows. - ArenaSeasonImporter: singleton (Id=1) Take Two arena season. - BattlePassImporter: per-level reward blobs. - DailyLoginBonusImporter: per-bonus-id campaign blobs. - PreReleaseInfoImporter: singleton (Id=1) pre-release window. Seed DTOs under SVSim.Bootstrap/Models/Seed/ mirror the seed JSON via [JsonPropertyName] snake_case. Raw-JSON columns (reward_data, format_info, etc.) use JsonElement on the seed and JsonSerializer.Serialize in the importer. Tests: 7 new happy-path tests in LoadIndexImporterTests.cs (idempotency covered by BattlePass spot-check). Full suite: 382/382 passing (375 + 7). NOT modifying in this stage: GlobalsImporter.cs (Stage 9C strips the old methods), Program.cs (Stage 9C wires up all 9 importers), SVSimTestFactory (Stage 9C). Double-writing on bootstrap is expected and OK during 9B. --- .../Importers/ArenaSeasonImporter.cs | 37 ++++++ .../Importers/AvatarAbilityImporter.cs | 41 ++++++ .../Importers/BattlePassImporter.cs | 37 ++++++ .../Importers/DailyLoginBonusImporter.cs | 37 ++++++ .../Importers/MyRotationImporter.cs | 53 ++++++++ .../Importers/PreReleaseInfoImporter.cs | 46 +++++++ .../Importers/RotationConfigImporter.cs | 102 +++++++++++++++ .../Models/Seed/ArenaSeasonSeed.cs | 20 +++ .../Models/Seed/AvatarAbilitySeed.cs | 17 +++ .../Models/Seed/BattlePassLevelSeed.cs | 11 ++ .../Models/Seed/DailyLoginBonusSeed.cs | 11 ++ .../Models/Seed/MyRotationAbilitySeed.cs | 11 ++ .../Models/Seed/MyRotationSettingSeed.cs | 18 +++ .../Models/Seed/PreReleaseInfoSeed.cs | 25 ++++ .../Models/Seed/RotationConfigSeed.cs | 41 ++++++ .../Importers/LoadIndexImporterTests.cs | 118 ++++++++++++++++++ 16 files changed, 625 insertions(+) create mode 100644 SVSim.Bootstrap/Importers/ArenaSeasonImporter.cs create mode 100644 SVSim.Bootstrap/Importers/AvatarAbilityImporter.cs create mode 100644 SVSim.Bootstrap/Importers/BattlePassImporter.cs create mode 100644 SVSim.Bootstrap/Importers/DailyLoginBonusImporter.cs create mode 100644 SVSim.Bootstrap/Importers/MyRotationImporter.cs create mode 100644 SVSim.Bootstrap/Importers/PreReleaseInfoImporter.cs create mode 100644 SVSim.Bootstrap/Importers/RotationConfigImporter.cs create mode 100644 SVSim.Bootstrap/Models/Seed/ArenaSeasonSeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/AvatarAbilitySeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/BattlePassLevelSeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/DailyLoginBonusSeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/MyRotationAbilitySeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/MyRotationSettingSeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/PreReleaseInfoSeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/RotationConfigSeed.cs create mode 100644 SVSim.UnitTests/Importers/LoadIndexImporterTests.cs diff --git a/SVSim.Bootstrap/Importers/ArenaSeasonImporter.cs b/SVSim.Bootstrap/Importers/ArenaSeasonImporter.cs new file mode 100644 index 0000000..67d1e6c --- /dev/null +++ b/SVSim.Bootstrap/Importers/ArenaSeasonImporter.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Models; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Singleton upsert (Id=1) of the active Take Two arena season config from +/// seeds/arena-season.json. format_info is preserved verbatim as a jsonb blob. +/// +public class ArenaSeasonImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + var s = SeedLoader.LoadObject(Path.Combine(seedDir, "arena-season.json")); + if (s is null) return 0; + + var existing = await context.ArenaSeasons.FirstOrDefaultAsync(e => e.Id == 1); + var entry = existing ?? new ArenaSeasonConfig { Id = 1 }; + entry.Mode = s.Mode; + entry.Enable = s.Enable; + entry.Cost = s.Cost; + entry.RupyCost = s.RupyCost; + entry.TicketCost = s.TicketCost; + entry.IsJoin = s.IsJoin; + entry.FormatInfo = s.FormatInfo.ValueKind == JsonValueKind.Undefined + ? "{}" + : JsonSerializer.Serialize(s.FormatInfo); + if (existing is null) context.ArenaSeasons.Add(entry); + + await context.SaveChangesAsync(); + Console.WriteLine($"[ArenaSeasonImporter] {(existing is null ? "+1" : "~1")}"); + return 1; + } +} diff --git a/SVSim.Bootstrap/Importers/AvatarAbilityImporter.cs b/SVSim.Bootstrap/Importers/AvatarAbilityImporter.cs new file mode 100644 index 0000000..3038c70 --- /dev/null +++ b/SVSim.Bootstrap/Importers/AvatarAbilityImporter.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Models; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Idempotent upsert of Avatar (Hero) ability rows from seeds/avatar-abilities.json. +/// Keyed by leader_skin_id. Ability / passive-ability DSL strings are preserved verbatim. +/// +public class AvatarAbilityImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + var seed = SeedLoader.LoadList(Path.Combine(seedDir, "avatar-abilities.json")); + if (seed.Count == 0) return 0; + + var existing = await context.AvatarAbilities.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 AvatarAbilityEntry { Id = s.Id }; + entry.BattleStartFirstPlayerTurnBp = s.BattleStartFirstPlayerTurnBp; + entry.BattleStartSecondPlayerTurnBp = s.BattleStartSecondPlayerTurnBp; + entry.BattleStartMaxLife = s.BattleStartMaxLife; + entry.AbilityCost = s.AbilityCost; + entry.Ability = s.Ability; + entry.PassiveAbility = s.PassiveAbility; + entry.AbilityDesc = s.AbilityDesc; + entry.PassiveAbilityDesc = s.PassiveAbilityDesc; + if (ex is null) { context.AvatarAbilities.Add(entry); existing[s.Id] = entry; created++; } + else updated++; + } + + await context.SaveChangesAsync(); + Console.WriteLine($"[AvatarAbilityImporter] +{created}/~{updated}"); + return created + updated; + } +} diff --git a/SVSim.Bootstrap/Importers/BattlePassImporter.cs b/SVSim.Bootstrap/Importers/BattlePassImporter.cs new file mode 100644 index 0000000..b29d091 --- /dev/null +++ b/SVSim.Bootstrap/Importers/BattlePassImporter.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Models; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Idempotent upsert of battle-pass level rows from seeds/battle-pass-levels.json. +/// Per-level reward_data blob preserved verbatim (shape varies per level). +/// +public class BattlePassImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + var seed = SeedLoader.LoadList(Path.Combine(seedDir, "battle-pass-levels.json")); + if (seed.Count == 0) return 0; + + var existing = await context.BattlePassLevels.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 BattlePassLevelEntry { Id = s.Id }; + entry.RewardData = s.RewardData.ValueKind == JsonValueKind.Undefined + ? "{}" + : JsonSerializer.Serialize(s.RewardData); + if (ex is null) { context.BattlePassLevels.Add(entry); existing[s.Id] = entry; created++; } + else updated++; + } + + await context.SaveChangesAsync(); + Console.WriteLine($"[BattlePassImporter] +{created}/~{updated}"); + return created + updated; + } +} diff --git a/SVSim.Bootstrap/Importers/DailyLoginBonusImporter.cs b/SVSim.Bootstrap/Importers/DailyLoginBonusImporter.cs new file mode 100644 index 0000000..1c158f1 --- /dev/null +++ b/SVSim.Bootstrap/Importers/DailyLoginBonusImporter.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Models; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Idempotent upsert of daily-login-bonus campaign rows from seeds/daily-login-bonus.json. +/// bonus_data array preserved verbatim — prod observed empty arrays outside active events. +/// +public class DailyLoginBonusImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + var seed = SeedLoader.LoadList(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; + } +} diff --git a/SVSim.Bootstrap/Importers/MyRotationImporter.cs b/SVSim.Bootstrap/Importers/MyRotationImporter.cs new file mode 100644 index 0000000..0484a8a --- /dev/null +++ b/SVSim.Bootstrap/Importers/MyRotationImporter.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Models; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Idempotent upsert of MyRotation reference data: settings (per rotation_id) + +/// abilities (per ability_id). Seeds come from seeds/my-rotation-settings.json and +/// seeds/my-rotation-abilities.json; the extractor pre-joins the original wire's three +/// dicts (setting, reprinted, restricted) on rotation_id, so the importer just iterates. +/// +public class MyRotationImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + var settings = SeedLoader.LoadList(Path.Combine(seedDir, "my-rotation-settings.json")); + var abilities = SeedLoader.LoadList(Path.Combine(seedDir, "my-rotation-abilities.json")); + + if (settings.Count == 0 && abilities.Count == 0) return 0; + + int sCreated = 0, sUpdated = 0; + var existingSettings = await context.MyRotationSettings.ToDictionaryAsync(e => e.Id); + foreach (var s in settings) + { + if (s.Id == 0) continue; + var entry = existingSettings.TryGetValue(s.Id, out var ex) ? ex : new MyRotationSettingEntry { Id = s.Id }; + entry.CardSetIdsCsv = s.CardSetIdsCsv; + entry.AbilitiesCsv = s.AbilitiesCsv; + entry.ReprintedCardIds = s.ReprintedCardIds; + entry.RestrictedCardIds = s.RestrictedCardIds; + if (ex is null) { context.MyRotationSettings.Add(entry); existingSettings[s.Id] = entry; sCreated++; } + else sUpdated++; + } + + int aCreated = 0, aUpdated = 0; + var existingAbilities = await context.MyRotationAbilities.ToDictionaryAsync(e => e.Id); + foreach (var s in abilities) + { + if (s.Id == 0) continue; + var entry = existingAbilities.TryGetValue(s.Id, out var ex) ? ex : new MyRotationAbilityEntry { Id = s.Id }; + entry.Data = JsonSerializer.Serialize(s.Data); + if (ex is null) { context.MyRotationAbilities.Add(entry); existingAbilities[s.Id] = entry; aCreated++; } + else aUpdated++; + } + + await context.SaveChangesAsync(); + Console.WriteLine($"[MyRotationImporter] settings +{sCreated}/~{sUpdated}, abilities +{aCreated}/~{aUpdated}"); + return sCreated + sUpdated + aCreated + aUpdated; + } +} diff --git a/SVSim.Bootstrap/Importers/PreReleaseInfoImporter.cs b/SVSim.Bootstrap/Importers/PreReleaseInfoImporter.cs new file mode 100644 index 0000000..0ca4168 --- /dev/null +++ b/SVSim.Bootstrap/Importers/PreReleaseInfoImporter.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Models; +using static SVSim.Bootstrap.Importers.ImporterBase; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Singleton upsert (Id=1) of the pre-release window from seeds/pre-release-info.json. +/// Card-id list / dict blobs are preserved verbatim into their jsonb columns; date strings go +/// through . +/// +public class PreReleaseInfoImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + var s = SeedLoader.LoadObject(Path.Combine(seedDir, "pre-release-info.json")); + if (s is null) return 0; + + var existing = await context.PreReleaseInfos.FirstOrDefaultAsync(e => e.Id == 1); + var entry = existing ?? new PreReleaseInfo { Id = 1 }; + entry.PreReleaseId = s.PreReleaseId; + entry.NextCardSetId = s.NextCardSetId; + entry.StartTime = ParseWireDateTime(s.StartTime); + entry.EndTime = ParseWireDateTime(s.EndTime); + entry.DisplayEndTime = ParseWireDateTime(s.DisplayEndTime); + entry.FreeMatchStartTime = ParseWireDateTime(s.FreeMatchStartTime); + entry.CardMasterId = s.CardMasterId; + entry.DefaultCardMasterId = s.DefaultCardMasterId; + entry.PreReleaseCardMasterId = s.PreReleaseCardMasterId; + entry.IsPreRotationFreeMatchTerm = s.IsPreRotationFreeMatchTerm; + entry.RotationCardSetIdList = s.RotationCardSetIdList.ValueKind == JsonValueKind.Undefined + ? "[]" : JsonSerializer.Serialize(s.RotationCardSetIdList); + entry.ReprintedBaseCardIds = s.ReprintedBaseCardIds.ValueKind == JsonValueKind.Undefined + ? "{}" : JsonSerializer.Serialize(s.ReprintedBaseCardIds); + entry.LatestReprintedBaseCardIds = s.LatestReprintedBaseCardIds.ValueKind == JsonValueKind.Undefined + ? "{}" : JsonSerializer.Serialize(s.LatestReprintedBaseCardIds); + if (existing is null) context.PreReleaseInfos.Add(entry); + + await context.SaveChangesAsync(); + Console.WriteLine($"[PreReleaseInfoImporter] {(existing is null ? "+1" : "~1")}"); + return 1; + } +} diff --git a/SVSim.Bootstrap/Importers/RotationConfigImporter.cs b/SVSim.Bootstrap/Importers/RotationConfigImporter.cs new file mode 100644 index 0000000..21bf238 --- /dev/null +++ b/SVSim.Bootstrap/Importers/RotationConfigImporter.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.Database.Models.Config; +using static SVSim.Bootstrap.Importers.ImporterBase; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Writes three GameConfigSection rows from the load-index seed split: +/// seeds/rotation-config.json → Rotation, seeds/challenge-config.json → Challenge, +/// seeds/my-rotation-schedule.json → MyRotationSchedule. Atomic section pattern: read the +/// existing section row (or shipped defaults), mutate the deserialized POCO, write back to +/// ValueJson. Re-runnable; rows missing from the seed leave the section row untouched. +/// +public class RotationConfigImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + int touched = 0; + + var rot = SeedLoader.LoadObject(Path.Combine(seedDir, "rotation-config.json")); + if (rot is not null) + { + await UpsertSection(context, RotationConfig.ShippedDefaults, c => + { + c.TsRotationId = rot.TsRotationId; + c.IsBattlePassPeriod = rot.IsBattlePassPeriod; + c.IsBeginnerMission = rot.IsBeginnerMission; + c.CardSetIdForResourceDlView = rot.CardSetIdForResourceDlView; + }); + touched++; + } + + var cc = SeedLoader.LoadObject(Path.Combine(seedDir, "challenge-config.json")); + if (cc is not null) + { + await UpsertSection(context, ChallengeConfig.ShippedDefaults, c => + { + c.UseTwoPickPremiumCard = cc.UseTwoPickPremiumCard; + c.TwoPickSleeveId = cc.TwoPickSleeveId; + }); + touched++; + } + + var schedule = SeedLoader.LoadObject(Path.Combine(seedDir, "my-rotation-schedule.json")); + if (schedule?.Gathering is not null && schedule.FreeBattle is not null) + { + var gBegin = ParseWireDateTime(schedule.Gathering.Begin); + var gEnd = ParseWireDateTime(schedule.Gathering.End); + var fBegin = ParseWireDateTime(schedule.FreeBattle.Begin); + var fEnd = ParseWireDateTime(schedule.FreeBattle.End); + // Only commit when both windows parsed to real DateTimes — a malformed/0001 value + // would silently lock the MyRotation feature off (the original bug the section fixed). + if (gBegin != DateTime.MinValue && gEnd != DateTime.MinValue + && fBegin != DateTime.MinValue && fEnd != DateTime.MinValue) + { + await UpsertSection(context, MyRotationScheduleConfig.ShippedDefaults, c => + { + c.Gathering = new ScheduleWindow { Begin = gBegin, End = gEnd }; + c.FreeBattle = new ScheduleWindow { Begin = fBegin, End = fEnd }; + }); + touched++; + } + else + { + Console.Error.WriteLine("[RotationConfigImporter] my-rotation-schedule.json windows malformed — keeping existing/shipped MyRotationSchedule."); + } + } + + await context.SaveChangesAsync(); + Console.WriteLine($"[RotationConfigImporter] sections={touched}"); + return touched; + } + + // Verbatim copy of GlobalsImporter.UpsertSection. Kept private-static here so this + // importer can stand alone after Stage 9C strips the GlobalsImporter copy. + private static async Task UpsertSection(SVSimDbContext context, Func shippedDefaults, Action mutate) + where T : class, new() + { + var sectionName = typeof(T).GetCustomAttributes(typeof(ConfigSectionAttribute), inherit: false) + .Cast().FirstOrDefault()?.Name + ?? throw new InvalidOperationException($"{typeof(T).Name} is missing [ConfigSection]."); + + var row = await context.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == sectionName); + T value; + if (row is null) + { + value = shippedDefaults(); + row = new GameConfigSection { SectionName = sectionName }; + context.GameConfigs.Add(row); + } + else + { + value = JsonSerializer.Deserialize(row.ValueJson) ?? shippedDefaults(); + } + mutate(value); + row.ValueJson = JsonSerializer.Serialize(value); + } +} diff --git a/SVSim.Bootstrap/Models/Seed/ArenaSeasonSeed.cs b/SVSim.Bootstrap/Models/Seed/ArenaSeasonSeed.cs new file mode 100644 index 0000000..be3080b --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/ArenaSeasonSeed.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +/// +/// Mirrors seeds/arena-season.json. Singleton (id=1) holding the Take Two arena season. +/// format_info is a nested JSON object stored verbatim as the entity's FormatInfo jsonb. +/// +public sealed class ArenaSeasonSeed +{ + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("mode")] public int Mode { get; set; } + [JsonPropertyName("enable")] public int Enable { get; set; } + [JsonPropertyName("cost")] public ulong Cost { get; set; } + [JsonPropertyName("rupy_cost")] public ulong RupyCost { get; set; } + [JsonPropertyName("ticket_cost")] public int TicketCost { get; set; } + [JsonPropertyName("is_join")] public bool IsJoin { get; set; } + [JsonPropertyName("format_info")] public JsonElement FormatInfo { get; set; } +} diff --git a/SVSim.Bootstrap/Models/Seed/AvatarAbilitySeed.cs b/SVSim.Bootstrap/Models/Seed/AvatarAbilitySeed.cs new file mode 100644 index 0000000..95bb220 --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/AvatarAbilitySeed.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +/// Mirrors seeds/avatar-abilities.json. One row per leader_skin_id. +public sealed class AvatarAbilitySeed +{ + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("battle_start_first_player_turn_bp")] public int BattleStartFirstPlayerTurnBp { get; set; } + [JsonPropertyName("battle_start_second_player_turn_bp")] public int BattleStartSecondPlayerTurnBp { get; set; } + [JsonPropertyName("battle_start_max_life")] public int BattleStartMaxLife { get; set; } + [JsonPropertyName("ability_cost")] public string AbilityCost { get; set; } = ""; + [JsonPropertyName("ability")] public string Ability { get; set; } = ""; + [JsonPropertyName("passive_ability")] public string PassiveAbility { get; set; } = ""; + [JsonPropertyName("ability_desc")] public string AbilityDesc { get; set; } = ""; + [JsonPropertyName("passive_ability_desc")] public string PassiveAbilityDesc { get; set; } = ""; +} diff --git a/SVSim.Bootstrap/Models/Seed/BattlePassLevelSeed.cs b/SVSim.Bootstrap/Models/Seed/BattlePassLevelSeed.cs new file mode 100644 index 0000000..0869c8d --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/BattlePassLevelSeed.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +/// Mirrors seeds/battle-pass-levels.json. reward_data preserved verbatim. +public sealed class BattlePassLevelSeed +{ + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("reward_data")] public JsonElement RewardData { get; set; } +} diff --git a/SVSim.Bootstrap/Models/Seed/DailyLoginBonusSeed.cs b/SVSim.Bootstrap/Models/Seed/DailyLoginBonusSeed.cs new file mode 100644 index 0000000..e64ed76 --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/DailyLoginBonusSeed.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +/// Mirrors seeds/daily-login-bonus.json. bonus_data preserved verbatim. +public sealed class DailyLoginBonusSeed +{ + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("bonus_data")] public JsonElement BonusData { get; set; } +} diff --git a/SVSim.Bootstrap/Models/Seed/MyRotationAbilitySeed.cs b/SVSim.Bootstrap/Models/Seed/MyRotationAbilitySeed.cs new file mode 100644 index 0000000..739a937 --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/MyRotationAbilitySeed.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +/// Mirrors seeds/my-rotation-abilities.json. data is preserved as raw JSON. +public sealed class MyRotationAbilitySeed +{ + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("data")] public JsonElement Data { get; set; } +} diff --git a/SVSim.Bootstrap/Models/Seed/MyRotationSettingSeed.cs b/SVSim.Bootstrap/Models/Seed/MyRotationSettingSeed.cs new file mode 100644 index 0000000..350c392 --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/MyRotationSettingSeed.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +/// +/// Mirrors seeds/my-rotation-settings.json. The extractor pre-joins +/// my_rotation_info.{setting, reprinted_base_card_ids, restricted_base_card_id_list} on +/// rotation_id into one flat list. reprinted_card_ids and restricted_card_ids are +/// pre-serialized JSON strings (verbatim from the wire) — the importer stores them verbatim. +/// +public sealed class MyRotationSettingSeed +{ + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("card_set_ids_csv")] public string CardSetIdsCsv { get; set; } = ""; + [JsonPropertyName("abilities_csv")] public string AbilitiesCsv { get; set; } = ""; + [JsonPropertyName("reprinted_card_ids")] public string ReprintedCardIds { get; set; } = "[]"; + [JsonPropertyName("restricted_card_ids")] public string RestrictedCardIds { get; set; } = "[]"; +} diff --git a/SVSim.Bootstrap/Models/Seed/PreReleaseInfoSeed.cs b/SVSim.Bootstrap/Models/Seed/PreReleaseInfoSeed.cs new file mode 100644 index 0000000..29c8f49 --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/PreReleaseInfoSeed.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +/// +/// Mirrors seeds/pre-release-info.json. Singleton (id=1). Card-id lists are kept as raw +/// JSON elements so they round-trip verbatim into the entity's jsonb columns. +/// +public sealed class PreReleaseInfoSeed +{ + [JsonPropertyName("pre_release_id")] public string PreReleaseId { get; set; } = ""; + [JsonPropertyName("next_card_set_id")] public string NextCardSetId { get; set; } = ""; + [JsonPropertyName("start_time")] public string StartTime { get; set; } = ""; + [JsonPropertyName("end_time")] public string EndTime { get; set; } = ""; + [JsonPropertyName("display_end_time")] public string DisplayEndTime { get; set; } = ""; + [JsonPropertyName("free_match_start_time")] public string FreeMatchStartTime { get; set; } = ""; + [JsonPropertyName("card_master_id")] public int CardMasterId { get; set; } + [JsonPropertyName("default_card_master_id")] public string DefaultCardMasterId { get; set; } = ""; + [JsonPropertyName("pre_release_card_master_id")] public string PreReleaseCardMasterId { get; set; } = ""; + [JsonPropertyName("is_pre_rotation_free_match_term")] public bool IsPreRotationFreeMatchTerm { get; set; } + [JsonPropertyName("rotation_card_set_id_list")] public JsonElement RotationCardSetIdList { get; set; } + [JsonPropertyName("reprinted_base_card_ids")] public JsonElement ReprintedBaseCardIds { get; set; } + [JsonPropertyName("latest_reprinted_base_card_ids")] public JsonElement LatestReprintedBaseCardIds { get; set; } +} diff --git a/SVSim.Bootstrap/Models/Seed/RotationConfigSeed.cs b/SVSim.Bootstrap/Models/Seed/RotationConfigSeed.cs new file mode 100644 index 0000000..f246499 --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/RotationConfigSeed.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +/// +/// Mirrors seeds/rotation-config.json. Drives the Rotation GameConfigSection. +/// Note: rotation_card_set_ids is the rotation CardSet flag list — consumed by +/// RotationFlagUpdater in Stage 9C, not by RotationConfigImporter. +/// +public sealed class RotationConfigSeed +{ + [JsonPropertyName("ts_rotation_id")] public string TsRotationId { get; set; } = ""; + [JsonPropertyName("is_battle_pass_period")] public bool IsBattlePassPeriod { get; set; } + [JsonPropertyName("is_beginner_mission")] public bool IsBeginnerMission { get; set; } + [JsonPropertyName("card_set_id_for_resource_dl_view")] public int CardSetIdForResourceDlView { get; set; } + [JsonPropertyName("rotation_card_set_ids")] public List RotationCardSetIds { get; set; } = new(); +} + +/// Mirrors seeds/challenge-config.json. Drives the Challenge GameConfigSection. +public sealed class ChallengeConfigSeed +{ + [JsonPropertyName("use_two_pick_premium_card")] public bool UseTwoPickPremiumCard { get; set; } + [JsonPropertyName("two_pick_sleeve_id")] public long TwoPickSleeveId { get; set; } +} + +/// +/// Mirrors seeds/my-rotation-schedule.json. Drives the MyRotationSchedule +/// GameConfigSection. The extractor pre-joins gathering and free_battle +/// from my_rotation_info.schedules into two top-level fields. +/// +public sealed class MyRotationScheduleSeed +{ + [JsonPropertyName("gathering")] public ScheduleWindowSeed? Gathering { get; set; } + [JsonPropertyName("free_battle")] public ScheduleWindowSeed? FreeBattle { get; set; } +} + +public sealed class ScheduleWindowSeed +{ + [JsonPropertyName("begin")] public string Begin { get; set; } = ""; + [JsonPropertyName("end")] public string End { get; set; } = ""; +} diff --git a/SVSim.UnitTests/Importers/LoadIndexImporterTests.cs b/SVSim.UnitTests/Importers/LoadIndexImporterTests.cs new file mode 100644 index 0000000..7e3a4ad --- /dev/null +++ b/SVSim.UnitTests/Importers/LoadIndexImporterTests.cs @@ -0,0 +1,118 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Bootstrap.Importers; +using SVSim.Database; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Importers; + +/// +/// Happy-path coverage for the 7 load-index importer classes introduced in Stage 9B +/// (RotationConfig, MyRotation, AvatarAbility, ArenaSeason, BattlePass, DailyLoginBonus, +/// PreReleaseInfo). Each test instantiates the importer in isolation and verifies it inserts +/// rows from the corresponding seed file under Data/seeds/. Idempotency is spot-checked +/// in one test (BattlePass) to avoid duplicating the canonical 4-test set per importer. +/// +public class LoadIndexImporterTests +{ + private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds"); + + [Test] + public async Task RotationConfigImporter_writes_game_config_sections() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new RotationConfigImporter().ImportAsync(db, SeedDir); + + var rows = await db.GameConfigs.ToListAsync(); + Assert.That(rows.Any(r => r.SectionName == "Rotation"), Is.True, + "Rotation section must be written from rotation-config.json"); + } + + [Test] + public async Task MyRotationImporter_writes_settings_and_abilities() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new MyRotationImporter().ImportAsync(db, SeedDir); + + Assert.That(await db.MyRotationSettings.CountAsync(), Is.GreaterThan(0), + "my-rotation-settings.json must produce setting rows"); + Assert.That(await db.MyRotationAbilities.CountAsync(), Is.GreaterThan(0), + "my-rotation-abilities.json must produce ability rows"); + } + + [Test] + public async Task AvatarAbilityImporter_writes_abilities() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new AvatarAbilityImporter().ImportAsync(db, SeedDir); + + Assert.That(await db.AvatarAbilities.CountAsync(), Is.GreaterThan(0), + "avatar-abilities.json must produce ability rows"); + } + + [Test] + public async Task ArenaSeasonImporter_writes_singleton() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new ArenaSeasonImporter().ImportAsync(db, SeedDir); + + var row = await db.ArenaSeasons.FirstOrDefaultAsync(e => e.Id == 1); + Assert.That(row, Is.Not.Null, "ArenaSeason singleton id=1 must be written"); + Assert.That(row!.FormatInfo, Is.Not.EqualTo("{}"), "format_info blob must be populated from seed"); + } + + [Test] + public async Task BattlePassImporter_writes_levels_and_is_idempotent() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new BattlePassImporter().ImportAsync(db, SeedDir); + int after1 = await db.BattlePassLevels.CountAsync(); + await new BattlePassImporter().ImportAsync(db, SeedDir); + int after2 = await db.BattlePassLevels.CountAsync(); + + Assert.That(after1, Is.GreaterThan(0), "battle-pass-levels.json must produce rows"); + Assert.That(after2, Is.EqualTo(after1), "rerun must be idempotent (no new rows)"); + } + + [Test] + public async Task DailyLoginBonusImporter_writes_bonus_rows() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new PreReleaseInfoImporter().ImportAsync(db, SeedDir); + + var row = await db.PreReleaseInfos.FirstOrDefaultAsync(e => e.Id == 1); + Assert.That(row, Is.Not.Null, "PreReleaseInfo singleton id=1 must be written"); + Assert.That(row!.PreReleaseId, Is.Not.Empty, "pre_release_id field must be populated"); + } +}