using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Models.Config; using static SVSim.Bootstrap.Importers.ImporterBase; namespace SVSim.Bootstrap.Importers; /// /// Imports prod-captured globals from {capturesDir}/{endpoint}-*.json snapshots into the /// DB via idempotent upserts. Source endpoints: load-index, mypage-index, deck-info. /// /// Topological order: GameConfiguration extensions → standalone tables → card-referencing tables → /// rotation CardSet flag update. Card-referencing importers warn on orphans (missing card rows) /// but never fail — CardImporter must have run first for clean output. /// /// Re-runnable on the same capture (no-op delta) and on updated captures (creates/updates only). /// Does NOT delete rows missing from the latest capture — that would risk data loss if a capture /// file is partial. Use a fresh DB for snapshot-only state. /// public class GlobalsImporter { public async Task ImportAllAsync(SVSimDbContext context, string capturesDir) { Console.WriteLine($"[GlobalsImporter] Loading captures from {capturesDir}..."); JsonElement? loadIndex = LoadCapture(capturesDir, "load-index"); JsonElement? mypageIndex = LoadCapture(capturesDir, "mypage-index"); JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info"); JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list"); JsonElement? practiceInfo = LoadCapture(capturesDir, "practice-info"); JsonElement? packInfo = LoadCapture(capturesDir, "pack-info"); JsonElement? basicPuzzleInfo = LoadCapture(capturesDir, "basic-puzzle-info"); JsonElement? basicPuzzleMission = LoadCapture(capturesDir, "basic-puzzle-mission"); int total = 0; if (loadIndex.HasValue) { total += await ImportGameConfigurationExtensions(context, loadIndex.Value); total += await ImportMyRotation(context, loadIndex.Value); total += await ImportAvatarAbilities(context, loadIndex.Value); total += await ImportArenaSeason(context, loadIndex.Value); total += await ImportBattlePassLevels(context, loadIndex.Value); total += await ImportDailyLoginBonus(context, loadIndex.Value); total += await ImportPreReleaseInfo(context, loadIndex.Value); total += await ImportSpotCards(context, loadIndex.Value); total += await ImportReprintedCards(context, loadIndex.Value); total += await ImportUnlimitedRestrictions(context, loadIndex.Value); total += await ImportLoadingExclusionCards(context, loadIndex.Value); total += await ImportMaintenanceCards(context, loadIndex.Value); total += await ImportFeatureMaintenances(context, loadIndex.Value); total += await UpdateRotationCardSetFlags(context, loadIndex.Value); } if (mypageIndex.HasValue) { total += await ImportBanners(context, mypageIndex.Value); total += await ImportColosseum(context, mypageIndex.Value); total += await ImportSealed(context, mypageIndex.Value); total += await ImportMasterPointRankingPeriod(context, mypageIndex.Value); total += await ImportRoomTypeInSession(context, mypageIndex.Value); } if (deckInfo.HasValue) { total += await ImportDefaultDecks(context, deckInfo.Value); total += await ImportDefaultLeaderSkinSettings(context, deckInfo.Value); } if (paymentItemList.HasValue) { total += await ImportPaymentItems(context, paymentItemList.Value); } if (practiceInfo.HasValue) { total += await ImportPracticeOpponents(context, practiceInfo.Value); } if (packInfo.HasValue) { total += await ImportPacks(context, packInfo.Value); } if (basicPuzzleInfo.HasValue) { total += await ImportPuzzleGroups(context, basicPuzzleInfo.Value); total += await ImportPuzzles(context, basicPuzzleInfo.Value); } if (basicPuzzleMission.HasValue) { total += await ImportPuzzleMissions(context, basicPuzzleMission.Value); } await context.SaveChangesAsync(); Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed."); return total; } // ---------- GameConfig sections ---------- private async Task ImportGameConfigurationExtensions(SVSimDbContext context, JsonElement loadIndex) { // Reads the prod capture and overwrites the Rotation and (optionally) Challenge sections // in GameConfigs. Sections are atomic — we read the existing row (or shipped defaults if // none), mutate, then serialize back to ValueJson. Each section is one row in GameConfigs. int touched = 0; await UpsertSection(context, RotationConfig.ShippedDefaults, rot => { rot.TsRotationId = GetString(loadIndex, "ts_rotation_id"); rot.IsBattlePassPeriod = GetBool(loadIndex, "is_battle_pass_period"); rot.IsBeginnerMission = GetBool(loadIndex, "is_beginner_mission"); rot.CardSetIdForResourceDlView = GetInt(loadIndex, "card_set_id_for_resource_dl_view"); Console.WriteLine($"[GlobalsImporter] GameConfigs/Rotation: ts_rotation_id={rot.TsRotationId}"); }); touched++; if (loadIndex.TryGetProperty("challenge_config", out var cc)) { await UpsertSection(context, ChallengeConfig.ShippedDefaults, ch => { ch.UseTwoPickPremiumCard = GetBool(cc, "use_challenge_two_pick_premium_card"); ch.TwoPickSleeveId = GetLong(cc, "challenge_two_pick_sleeve_id"); }); touched++; } // my_rotation_info.schedules → MyRotationSchedule section. Two named windows, hard-typed // on both wire and client (Wizard/MyRotationAllInfo.cs:178-192 reads "gathering" and // "free_battle" by name and binds them to typed fields). Only upsert when both windows // parse to real DateTimes — a missing or 0001-01-01 capture would lock the feature off, // which is exactly the bug the section was added to fix. if (loadIndex.TryGetProperty("my_rotation_info", out var mri) && mri.TryGetProperty("schedules", out var schedules)) { bool gOk = TryParseScheduleWindow(schedules, "gathering", out var gBegin, out var gEnd); bool fOk = TryParseScheduleWindow(schedules, "free_battle", out var fBegin, out var fEnd); if (gOk && fOk) { await UpsertSection(context, MyRotationScheduleConfig.ShippedDefaults, mr => { mr.Gathering = new ScheduleWindow { Begin = gBegin, End = gEnd }; mr.FreeBattle = new ScheduleWindow { Begin = fBegin, End = fEnd }; Console.WriteLine($"[GlobalsImporter] GameConfigs/MyRotationSchedule: free_battle {fBegin:u} → {fEnd:u}"); }); touched++; } else { Console.Error.WriteLine("[GlobalsImporter] my_rotation_info.schedules missing or malformed — keeping existing/shipped MyRotationSchedule."); } } return touched; } private static bool TryParseScheduleWindow(JsonElement schedules, string key, out DateTime begin, out DateTime end) { begin = default; end = default; if (!schedules.TryGetProperty(key, out var window) || window.ValueKind != JsonValueKind.Object) return false; if (!DateTime.TryParse(GetString(window, "begin_time"), out begin)) return false; if (!DateTime.TryParse(GetString(window, "end_time"), out end)) return false; return true; } 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); } // ---------- My Rotation ---------- private async Task ImportMyRotation(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("my_rotation_info", out var info)) return 0; // Settings — join setting + reprinted + restricted dicts on rotation_id. var settingsDict = info.TryGetProperty("setting", out var s) ? s : default; var reprintedDict = info.TryGetProperty("reprinted_base_card_ids", out var r) ? r : default; var restrictedDict = info.TryGetProperty("restricted_base_card_id_list", out var rs) ? rs : default; var existingSettings = await context.MyRotationSettings.ToDictionaryAsync(e => e.Id); int setCreated = 0, setUpdated = 0; if (settingsDict.ValueKind == JsonValueKind.Object) { foreach (var kv in settingsDict.EnumerateObject()) { if (!int.TryParse(kv.Name, out int rid)) continue; var entry = existingSettings.TryGetValue(rid, out var ex) ? ex : new MyRotationSettingEntry { Id = rid }; entry.CardSetIdsCsv = GetString(kv.Value, "card_set_ids"); entry.AbilitiesCsv = GetString(kv.Value, "abilities"); entry.ReprintedCardIds = reprintedDict.ValueKind == JsonValueKind.Object && reprintedDict.TryGetProperty(kv.Name, out var rep) ? Serialize(rep) : "[]"; entry.RestrictedCardIds = restrictedDict.ValueKind == JsonValueKind.Object && restrictedDict.TryGetProperty(kv.Name, out var res) ? Serialize(res) : "[]"; if (ex is null) { context.MyRotationSettings.Add(entry); setCreated++; } else setUpdated++; } } // Abilities int abilityCreated = 0, abilityUpdated = 0; if (info.TryGetProperty("abilities", out var abilities) && abilities.ValueKind == JsonValueKind.Object) { var existingAbilities = await context.MyRotationAbilities.ToDictionaryAsync(e => e.Id); foreach (var kv in abilities.EnumerateObject()) { if (!int.TryParse(kv.Name, out int aid)) continue; var entry = existingAbilities.TryGetValue(aid, out var ex) ? ex : new MyRotationAbilityEntry { Id = aid }; entry.Data = Serialize(kv.Value); if (ex is null) { context.MyRotationAbilities.Add(entry); abilityCreated++; } else abilityUpdated++; } } Console.WriteLine($"[GlobalsImporter] MyRotation: settings +{setCreated}/~{setUpdated}, abilities +{abilityCreated}/~{abilityUpdated}"); return setCreated + setUpdated + abilityCreated + abilityUpdated; } // ---------- Avatar Abilities ---------- private async Task ImportAvatarAbilities(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("avatar_info", out var info)) return 0; if (!info.TryGetProperty("abilities", out var abilities) || abilities.ValueKind != JsonValueKind.Object) return 0; var existing = await context.AvatarAbilities.ToDictionaryAsync(e => e.Id); int created = 0, updated = 0; foreach (var kv in abilities.EnumerateObject()) { if (!int.TryParse(kv.Name, out int leaderSkinId)) continue; var v = kv.Value; var entry = existing.TryGetValue(leaderSkinId, out var ex) ? ex : new AvatarAbilityEntry { Id = leaderSkinId }; entry.BattleStartFirstPlayerTurnBp = GetInt(v, "battle_start_firstplayerturn_bp"); entry.BattleStartSecondPlayerTurnBp = GetInt(v, "battle_start_secondplayerturn_bp"); entry.BattleStartMaxLife = GetInt(v, "battle_start_max_life"); entry.AbilityCost = GetString(v, "ability_cost"); entry.Ability = GetString(v, "ability"); entry.PassiveAbility = GetString(v, "passive_ability"); entry.AbilityDesc = GetString(v, "ability_desc"); entry.PassiveAbilityDesc = GetString(v, "passive_ability_desc"); if (ex is null) { context.AvatarAbilities.Add(entry); created++; } else updated++; } Console.WriteLine($"[GlobalsImporter] AvatarAbilities: +{created}/~{updated}"); return created + updated; } // ---------- Arena Season (singleton) ---------- private async Task ImportArenaSeason(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("arena_info", out var arr) || arr.ValueKind != JsonValueKind.Array || arr.GetArrayLength() == 0) return 0; var first = arr[0]; var existing = await context.ArenaSeasons.FirstOrDefaultAsync(e => e.Id == 1); var entry = existing ?? new ArenaSeasonConfig { Id = 1 }; entry.Mode = GetInt(first, "mode"); entry.Enable = GetInt(first, "enable"); entry.Cost = GetULong(first, "cost"); entry.RupyCost = GetULong(first, "rupy_cost"); entry.TicketCost = GetInt(first, "ticket_cost"); entry.IsJoin = GetBool(first, "is_join"); entry.FormatInfo = first.TryGetProperty("format_info", out var fi) ? Serialize(fi) : "{}"; if (existing is null) context.ArenaSeasons.Add(entry); Console.WriteLine($"[GlobalsImporter] ArenaSeason: {(existing is null ? "+1" : "~1")}"); return 1; } // ---------- Battle Pass Levels ---------- private async Task ImportBattlePassLevels(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("battle_pass_level_info", out var info) || info.ValueKind != JsonValueKind.Object) return 0; var existing = await context.BattlePassLevels.ToDictionaryAsync(e => e.Id); int created = 0, updated = 0; foreach (var kv in info.EnumerateObject()) { if (!int.TryParse(kv.Name, out int level)) continue; var entry = existing.TryGetValue(level, out var ex) ? ex : new BattlePassLevelEntry { Id = level }; entry.RewardData = Serialize(kv.Value); if (ex is null) { context.BattlePassLevels.Add(entry); created++; } else updated++; } Console.WriteLine($"[GlobalsImporter] BattlePassLevels: +{created}/~{updated}"); return created + updated; } // ---------- Daily Login Bonus ---------- private async Task ImportDailyLoginBonus(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("daily_login_bonus", out var info) || info.ValueKind != JsonValueKind.Object) return 0; var existing = await context.DailyLoginBonuses.ToDictionaryAsync(e => e.Id); int created = 0, updated = 0; foreach (var kv in info.EnumerateObject()) { if (!int.TryParse(kv.Name, out int bonusId)) continue; var entry = existing.TryGetValue(bonusId, out var ex) ? ex : new DailyLoginBonusEntry { Id = bonusId }; entry.BonusData = Serialize(kv.Value); if (ex is null) { context.DailyLoginBonuses.Add(entry); created++; } else updated++; } Console.WriteLine($"[GlobalsImporter] DailyLoginBonus: +{created}/~{updated}"); return created + updated; } // ---------- Pre-release Info (singleton) ---------- private async Task ImportPreReleaseInfo(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("pre_release_info", out var info) || info.ValueKind != JsonValueKind.Object) return 0; var existing = await context.PreReleaseInfos.FirstOrDefaultAsync(e => e.Id == 1); var entry = existing ?? new PreReleaseInfo { Id = 1 }; entry.PreReleaseId = GetString(info, "id"); entry.NextCardSetId = GetString(info, "next_card_set_id"); entry.StartTime = ParseWireDateTime(GetString(info, "start_time")); entry.EndTime = ParseWireDateTime(GetString(info, "end_time")); entry.DisplayEndTime = ParseWireDateTime(GetString(info, "display_end_time")); entry.FreeMatchStartTime = ParseWireDateTime(GetString(info, "free_match_start_time")); entry.CardMasterId = GetInt(info, "card_master_id"); entry.DefaultCardMasterId = GetString(info, "default_card_master_id"); entry.PreReleaseCardMasterId = GetString(info, "pre_release_card_master_id"); entry.IsPreRotationFreeMatchTerm = GetBool(info, "is_pre_rotation_free_match_term"); entry.RotationCardSetIdList = info.TryGetProperty("rotation_card_set_id_list", out var rcs) ? Serialize(rcs) : "[]"; entry.ReprintedBaseCardIds = info.TryGetProperty("reprinted_base_card_ids", out var rep) ? Serialize(rep) : "{}"; entry.LatestReprintedBaseCardIds = info.TryGetProperty("latest_reprinted_base_card_ids", out var lrep) ? Serialize(lrep) : "{}"; if (existing is null) context.PreReleaseInfos.Add(entry); Console.WriteLine($"[GlobalsImporter] PreReleaseInfo: {(existing is null ? "+1" : "~1")}"); return 1; } // ---------- Spot Cards (card-referencing) ---------- private async Task ImportSpotCards(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("spot_cards", out var info) || info.ValueKind != JsonValueKind.Object) return 0; var existing = await context.SpotCards.ToDictionaryAsync(e => e.Id); var knownCards = await context.Cards.Select(c => c.Id).ToListAsync(); var knownSet = new HashSet(knownCards); int created = 0, updated = 0, orphans = 0; foreach (var kv in info.EnumerateObject()) { if (!long.TryParse(kv.Name, out long cardId)) continue; if (!knownSet.Contains(cardId)) orphans++; int cost = kv.Value.ValueKind == JsonValueKind.Number ? kv.Value.GetInt32() : GetInt(kv.Value, "cost"); var entry = existing.TryGetValue(cardId, out var ex) ? ex : new SpotCardEntry { Id = cardId }; entry.Cost = cost; if (ex is null) { context.SpotCards.Add(entry); created++; } else updated++; } WarnOrphans("SpotCards", orphans); Console.WriteLine($"[GlobalsImporter] SpotCards: +{created}/~{updated}"); return created + updated; } // ---------- Reprinted Cards ---------- private async Task ImportReprintedCards(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("reprinted_base_card_ids", out var info)) return 0; var existing = await context.ReprintedCards.ToDictionaryAsync(e => e.Id); var knownSet = new HashSet(await context.Cards.Select(c => c.Id).ToListAsync()); int created = 0, orphans = 0; IEnumerable ids; if (info.ValueKind == JsonValueKind.Object) { ids = info.EnumerateObject().Select(kv => long.TryParse(kv.Name, out var n) ? n : 0L).Where(n => n != 0); } else if (info.ValueKind == JsonValueKind.Array) { ids = info.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.Number ? e.GetInt64() : (long.TryParse(e.GetString(), out var n) ? n : 0L)).Where(n => n != 0); } else return 0; foreach (var id in ids) { if (!knownSet.Contains(id)) orphans++; if (existing.ContainsKey(id)) continue; context.ReprintedCards.Add(new ReprintedCardEntry { Id = id }); existing[id] = null!; created++; } WarnOrphans("ReprintedCards", orphans); Console.WriteLine($"[GlobalsImporter] ReprintedCards: +{created}"); return created; } // ---------- Unlimited Restrictions ---------- private async Task ImportUnlimitedRestrictions(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("unlimited_restricted_base_card_id_list", out var info) || info.ValueKind != JsonValueKind.Object) return 0; var existing = await context.UnlimitedRestrictions.ToDictionaryAsync(e => e.Id); var knownSet = new HashSet(await context.Cards.Select(c => c.Id).ToListAsync()); int created = 0, updated = 0, orphans = 0; foreach (var kv in info.EnumerateObject()) { if (!long.TryParse(kv.Name, out long cardId)) continue; if (!knownSet.Contains(cardId)) orphans++; int val = kv.Value.ValueKind == JsonValueKind.Number ? kv.Value.GetInt32() : (int.TryParse(kv.Value.GetString(), out var n) ? n : 0); var entry = existing.TryGetValue(cardId, out var ex) ? ex : new UnlimitedRestrictionEntry { Id = cardId }; entry.RestrictionValue = val; if (ex is null) { context.UnlimitedRestrictions.Add(entry); created++; } else updated++; } WarnOrphans("UnlimitedRestrictions", orphans); Console.WriteLine($"[GlobalsImporter] UnlimitedRestrictions: +{created}/~{updated}"); return created + updated; } // ---------- Loading Exclusion Cards ---------- private async Task ImportLoadingExclusionCards(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("loading_exclusion_card_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0; var existing = await context.LoadingExclusionCards.ToDictionaryAsync(e => e.Id); var knownSet = new HashSet(await context.Cards.Select(c => c.Id).ToListAsync()); int created = 0, orphans = 0; foreach (var el in arr.EnumerateArray()) { long id = el.ValueKind == JsonValueKind.Number ? el.GetInt64() : (long.TryParse(el.GetString(), out var n) ? n : 0); if (id == 0) continue; if (!knownSet.Contains(id)) orphans++; if (existing.ContainsKey(id)) continue; context.LoadingExclusionCards.Add(new LoadingExclusionCardEntry { Id = id }); existing[id] = null!; created++; } WarnOrphans("LoadingExclusionCards", orphans); Console.WriteLine($"[GlobalsImporter] LoadingExclusionCards: +{created}"); return created; } // ---------- Maintenance Cards (skeleton-seedable) ---------- private async Task ImportMaintenanceCards(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("maintenance_card_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0; if (arr.GetArrayLength() == 0) return 0; var existing = await context.MaintenanceCards.ToDictionaryAsync(e => e.Id); int created = 0; foreach (var el in arr.EnumerateArray()) { long id = el.ValueKind == JsonValueKind.Number ? el.GetInt64() : (long.TryParse(el.GetString(), out var n) ? n : 0); if (id == 0 || existing.ContainsKey(id)) continue; context.MaintenanceCards.Add(new MaintenanceCardEntry { Id = id }); existing[id] = null!; created++; } Console.WriteLine($"[GlobalsImporter] MaintenanceCards: +{created}"); return created; } // ---------- Feature Maintenances (skeleton-seedable) ---------- private async Task ImportFeatureMaintenances(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("feature_maintenance_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0; if (arr.GetArrayLength() == 0) return 0; // Schema uses synthetic int Id; preserve raw blob per index. int created = 0; int idx = 1; foreach (var el in arr.EnumerateArray()) { context.FeatureMaintenances.Add(new FeatureMaintenanceEntry { Id = idx++, FeatureKey = GetString(el, "feature_key"), Data = Serialize(el) }); created++; } Console.WriteLine($"[GlobalsImporter] FeatureMaintenances: +{created}"); return created; } // ---------- Rotation CardSet flag update ---------- private async Task UpdateRotationCardSetFlags(SVSimDbContext context, JsonElement loadIndex) { if (!loadIndex.TryGetProperty("rotation_card_set_id_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0; var rotationIds = arr.EnumerateArray() .Select(e => e.TryGetProperty("card_set_id", out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt32() : 0) .Where(n => n != 0) .ToHashSet(); if (rotationIds.Count == 0) return 0; var allSets = await context.CardSets.ToListAsync(); int updated = 0, missing = 0; foreach (var rid in rotationIds) { var set = allSets.FirstOrDefault(s => s.Id == rid); if (set is null) { missing++; continue; } if (!set.IsInRotation) { set.IsInRotation = true; updated++; } } // Demote sets not in the current rotation foreach (var s in allSets.Where(s => s.IsInRotation && !rotationIds.Contains(s.Id))) { s.IsInRotation = false; updated++; } if (missing > 0) Console.Error.WriteLine($"[GlobalsImporter] Warning: {missing} rotation card_set_id(s) missing from CardSets — run CardImporter first."); Console.WriteLine($"[GlobalsImporter] RotationCardSets: ~{updated} flag changes"); return updated; } // ---------- Mypage: Banners ---------- private async Task ImportBanners(SVSimDbContext context, JsonElement mypage) { if (!mypage.TryGetProperty("banner", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0; // Banners have no wire ID; we treat the capture as authoritative — clear and rewrite. var existing = await context.Banners.ToListAsync(); context.Banners.RemoveRange(existing); int created = 0; int idx = 1; foreach (var el in arr.EnumerateArray()) { context.Banners.Add(new BannerEntry { Id = idx++, ImageName = GetString(el, "image_name"), Click = GetString(el, "click"), Status = GetString(el, "status"), ChangeTime = GetInt(el, "change_time"), RemainingTime = GetInt(el, "remaining_time"), ImagePaths = el.TryGetProperty("image_paths", out var ip) ? Serialize(ip) : "[]" }); created++; } Console.WriteLine($"[GlobalsImporter] Banners: {(existing.Count > 0 ? $"-{existing.Count}/" : "")}+{created}"); return created; } // ---------- Mypage: Colosseum (singleton) ---------- private async Task ImportColosseum(SVSimDbContext context, JsonElement mypage) { if (!mypage.TryGetProperty("colosseum_info", out var info) || info.ValueKind != JsonValueKind.Object) return 0; var existing = await context.Colosseums.FirstOrDefaultAsync(e => e.Id == 1); var entry = existing ?? new ColosseumConfig { Id = 1 }; entry.ColosseumId = GetString(info, "colosseum_id"); entry.ColosseumName = GetString(info, "colosseum_name"); entry.CardPoolName = GetString(info, "card_pool_name"); entry.DeckFormat = GetString(info, "deck_format"); entry.StartTime = ParseWireDateTime(GetString(info, "start_time")); entry.EndTime = ParseWireDateTime(GetString(info, "end_time")); entry.NowRound = GetString(info, "now_round"); entry.IsDisplayTips = GetString(info, "is_display_tips"); entry.TipsId = GetString(info, "tips_id"); entry.IsColosseumPeriod = GetBool(info, "is_colosseum_period"); entry.IsRoundPeriod = GetBool(info, "is_round_period"); entry.IsNormalTwoPick = GetString(info, "is_normal_two_pick"); entry.IsSpecialMode = GetString(info, "is_special_mode"); entry.IsAllCardEnabled = GetInt(info, "is_all_card_enabled"); entry.SalesPeriodInfo = info.TryGetProperty("sales_period_info", out var sp) ? Serialize(sp) : "{}"; if (existing is null) context.Colosseums.Add(entry); Console.WriteLine($"[GlobalsImporter] Colosseum: {(existing is null ? "+1" : "~1")}"); return 1; } // ---------- Mypage: Sealed (singleton) ---------- private async Task ImportSealed(SVSimDbContext context, JsonElement mypage) { if (!mypage.TryGetProperty("sealed_info", out var info) || info.ValueKind != JsonValueKind.Object) return 0; var existing = await context.SealedSeasons.FirstOrDefaultAsync(e => e.Id == 1); var entry = existing ?? new SealedConfig { Id = 1 }; entry.Enable = GetInt(info, "enable"); entry.CrystalCost = GetInt(info, "crystal_cost"); entry.RupyCost = GetInt(info, "rupy_cost"); entry.TicketCost = GetInt(info, "ticket_cost"); entry.DeckUsingNumMin = GetInt(info, "deck_using_num_min"); entry.ScheduleId = GetInt(info, "schedule_id"); entry.IsJoin = GetBool(info, "is_join"); entry.IsDeckCodeMaintenance = GetBool(info, "is_deck_code_maintenance"); entry.PackInfo = info.TryGetProperty("pack_info", out var pi) ? Serialize(pi) : "[]"; entry.SalesPeriodInfo = info.TryGetProperty("sales_period_info", out var sp) ? Serialize(sp) : "{}"; if (existing is null) context.SealedSeasons.Add(entry); Console.WriteLine($"[GlobalsImporter] Sealed: {(existing is null ? "+1" : "~1")}"); return 1; } // ---------- Mypage: Master Point Ranking Period ---------- private async Task ImportMasterPointRankingPeriod(SVSimDbContext context, JsonElement mypage) { if (!mypage.TryGetProperty("master_point_ranking_period", out var info) || info.ValueKind != JsonValueKind.Object) return 0; int id = GetInt(info, "id"); if (id == 0) return 0; var existing = await context.MasterPointRankingPeriods.FirstOrDefaultAsync(e => e.Id == id); var entry = existing ?? new MasterPointRankingPeriodEntry { Id = id }; entry.PeriodNum = GetInt(info, "period_num"); entry.NecessaryScore = GetLong(info, "necessary_score"); entry.BeginTime = ParseWireDateTime(GetString(info, "begin_time")); entry.EndTime = ParseWireDateTime(GetString(info, "end_time")); if (existing is null) context.MasterPointRankingPeriods.Add(entry); Console.WriteLine($"[GlobalsImporter] MasterPointRankingPeriod (id={id}): {(existing is null ? "+1" : "~1")}"); return 1; } // ---------- Mypage: Room Type In Session (special deck formats) ---------- private async Task ImportRoomTypeInSession(SVSimDbContext context, JsonElement mypage) { if (!mypage.TryGetProperty("room_type_in_session", out var rt) || rt.ValueKind != JsonValueKind.Object) return 0; if (!rt.TryGetProperty("special_deck_format_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0; // Same shape semantics as Banners — the wire has no stable id, treat the capture as // authoritative and clear-and-rewrite with a synthetic ordinal. var existing = await context.SpecialDeckFormats.ToListAsync(); context.SpecialDeckFormats.RemoveRange(existing); int created = 0; int idx = 1; foreach (var el in arr.EnumerateArray()) { context.SpecialDeckFormats.Add(new SpecialDeckFormatEntry { Id = idx++, DeckFormat = GetString(el, "deck_format"), EndTime = ParseWireDateTime(GetString(el, "end_time")) }); created++; } Console.WriteLine($"[GlobalsImporter] SpecialDeckFormats: {(existing.Count > 0 ? $"-{existing.Count}/" : "")}+{created}"); return created; } // ---------- Deck/info: Default Decks ---------- private async Task ImportDefaultDecks(SVSimDbContext context, JsonElement deckInfo) { if (!deckInfo.TryGetProperty("default_deck_list", out var info) || info.ValueKind != JsonValueKind.Object) return 0; var existing = await context.DefaultDecks.ToDictionaryAsync(e => e.Id); var knownSet = new HashSet(await context.Cards.Select(c => c.Id).ToListAsync()); int created = 0, updated = 0, orphans = 0; foreach (var kv in info.EnumerateObject()) { if (!int.TryParse(kv.Name, out int deckNo)) continue; var v = kv.Value; var entry = existing.TryGetValue(deckNo, out var ex) ? ex : new DefaultDeckEntry { Id = deckNo }; entry.ClassId = GetInt(v, "class_id"); entry.SleeveId = GetLong(v, "sleeve_id"); entry.LeaderSkinId = GetInt(v, "leader_skin_id"); entry.DeckName = GetString(v, "deck_name"); entry.CardIdArray = v.TryGetProperty("card_id_array", out var arr) ? Serialize(arr) : "[]"; // Count orphans against card master if (arr.ValueKind == JsonValueKind.Array) { foreach (var c in arr.EnumerateArray()) { if (c.ValueKind != JsonValueKind.Number) continue; if (!knownSet.Contains(c.GetInt64())) orphans++; } } if (ex is null) { context.DefaultDecks.Add(entry); created++; } else updated++; } WarnOrphans("DefaultDecks.card_id_array", orphans); Console.WriteLine($"[GlobalsImporter] DefaultDecks: +{created}/~{updated}"); return created + updated; } // ---------- Deck/info: Default Leader Skin Settings ---------- private async Task ImportDefaultLeaderSkinSettings(SVSimDbContext context, JsonElement deckInfo) { if (!deckInfo.TryGetProperty("user_leader_skin_setting_list", out var info) || info.ValueKind != JsonValueKind.Object) return 0; var existing = await context.DefaultLeaderSkinSettings.ToDictionaryAsync(e => e.Id); int created = 0, updated = 0; foreach (var kv in info.EnumerateObject()) { if (!int.TryParse(kv.Name, out int classId)) continue; var v = kv.Value; var entry = existing.TryGetValue(classId, out var ex) ? ex : new DefaultLeaderSkinSettingEntry { Id = classId }; entry.IsRandomLeaderSkin = GetInt(v, "is_random_leader_skin"); entry.LeaderSkinId = GetInt(v, "leader_skin_id"); if (ex is null) { context.DefaultLeaderSkinSettings.Add(entry); created++; } else updated++; } Console.WriteLine($"[GlobalsImporter] DefaultLeaderSkinSettings: +{created}/~{updated}"); return created + updated; } // ---------- Payment: Item list (Steam/PC storefront, dict-keyed by store_product_id) ---------- private async Task ImportPaymentItems(SVSimDbContext context, JsonElement payment) { // The payment-item-list capture's `data` IS the product dict (no nested key like banner/colosseum). // LoadCapture already unwrapped `data` for us, so iterate the dict directly. if (payment.ValueKind != JsonValueKind.Object) return 0; var existing = await context.PaymentItems.ToDictionaryAsync(e => e.Id); int created = 0, updated = 0; foreach (var kv in payment.EnumerateObject()) { var v = kv.Value; if (v.ValueKind != JsonValueKind.Object) continue; int recordId = GetInt(v, "record_id"); if (recordId == 0) continue; var entry = existing.TryGetValue(recordId, out var ex) ? ex : new PaymentItemEntry { Id = recordId }; entry.ProductId = GetInt(v, "id"); entry.StoreProductId = GetLong(v, "store_product_id"); entry.Name = GetString(v, "name"); entry.Text = GetString(v, "text"); entry.Price = ParseDecimal(GetString(v, "price")); entry.ChargeCrystalNum = GetInt(v, "charge_crystal_num"); entry.FreeCrystalNum = GetInt(v, "free_crystal_num"); entry.PurchaseLimit = GetInt(v, "purchase_limit"); entry.SpecialShopFlag = GetInt(v, "special_shop_flag"); entry.ImageName = GetString(v, "image_name"); entry.StartTime = ParseWireDateTime(GetString(v, "start_time")); entry.EndTime = ParseWireDateTime(GetString(v, "end_time")); entry.RemainingTime = GetInt(v, "remaining_time"); entry.IsResaleProduct = GetInt(v, "is_resale_product"); // resale_start_date is "" when unset — store null rather than DateTime.MinValue so the // controller can decide whether to emit "" or a real date string. string resaleRaw = GetString(v, "resale_start_date"); entry.ResaleStartDate = string.IsNullOrWhiteSpace(resaleRaw) ? null : ParseWireDateTime(resaleRaw); if (ex is null) { context.PaymentItems.Add(entry); created++; } else updated++; } Console.WriteLine($"[GlobalsImporter] PaymentItems: +{created}/~{updated}"); return created + updated; } private static decimal ParseDecimal(string s) => decimal.TryParse(s, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : 0m; // ---------- Pack catalog ---------- /// /// Imports /pack/info's pack_config_list into PackConfigEntry rows. The capture's data /// element wraps an object with a pack_config_list array; iterate that. Owned children /// (child_gacha_info, cardpack_banner_list) are replaced wholesale on re-runs — diffing /// owned collections by composite keys is more code than it's worth for catalog updates. /// private async Task ImportPacks(SVSimDbContext context, JsonElement packData) { if (!packData.TryGetProperty("pack_config_list", out var list) || list.ValueKind != JsonValueKind.Array) { Console.Error.WriteLine("[GlobalsImporter] pack-info capture missing 'pack_config_list'"); return 0; } var existing = await context.Packs .Include(p => p.ChildGachas) .Include(p => p.Banners) .ToDictionaryAsync(p => p.Id); int created = 0, updated = 0; foreach (var el in list.EnumerateArray()) { int parentId = GetInt(el, "parent_gacha_id"); if (parentId == 0) continue; var pack = existing.TryGetValue(parentId, out var ex) ? ex : new PackConfigEntry { Id = parentId }; pack.BasePackId = GetInt(el, "base_pack_id"); pack.GachaType = GetInt(el, "gacha_type"); pack.PackCategory = (PackCategory)GetInt(el, "pack_category"); pack.PosterType = GetInt(el, "poster_type"); pack.CommenceDate = ParseWireDateTime(GetString(el, "commence_date")); pack.CompleteDate = ParseWireDateTime(GetString(el, "complete_date")); pack.SleeveId = GetInt(el, "sleeve_id"); pack.SpecialSleeveId = GetInt(el, "special_sleeve_id"); pack.OverrideDrawEffectPackId = GetInt(el, "override_draw_effect_pack_id"); pack.OverrideUiEffectPackId = GetInt(el, "override_ui_effect_pack_id"); pack.GachaDetail = GetString(el, "gacha_detail"); pack.IsHide = GetBool(el, "is_hide"); pack.IsNew = GetBool(el, "is_new"); pack.IsPreRelease = GetBool(el, "is_pre_release"); pack.OpenCountLimit = GetInt(el, "open_count_limit"); // sales_period_info is `{}` when set (object with sales_period_time) and `[]` when unset if (el.TryGetProperty("sales_period_info", out var spi) && spi.ValueKind == JsonValueKind.Object) { var raw = GetString(spi, "sales_period_time"); pack.SalesPeriodTime = string.IsNullOrEmpty(raw) ? null : ParseWireDateTime(raw); } else { pack.SalesPeriodTime = null; } // gacha_point is null when the pack doesn't participate if (el.TryGetProperty("gacha_point", out var gp) && gp.ValueKind == JsonValueKind.Object) { pack.GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = GetInt(gp, "exchangeable_gacha_point"), IncreaseGachaPoint = GetInt(gp, "increase_gacha_point"), }; } else { pack.GachaPointConfig = null; } // Replace owned collections wholesale. pack.ChildGachas.Clear(); if (el.TryGetProperty("child_gacha_info", out var cg) && cg.ValueKind == JsonValueKind.Array) { foreach (var c in cg.EnumerateArray()) { pack.ChildGachas.Add(new PackChildGachaEntry { GachaId = GetInt(c, "gacha_id"), TypeDetail = GetInt(c, "type_detail"), Cost = GetInt(c, "cost"), CardCount = GetInt(c, "count", 8), ItemId = c.TryGetProperty("item_id", out var ii) && ii.ValueKind != JsonValueKind.Null ? GetLong(c, "item_id") : (long?)null, IsDailySingle = GetBool(c, "is_daily_single"), OverrideIncreaseGachaPoint = GetInt(c, "override_increase_gacha_point"), PurchaseLimitCount = GetInt(c, "purchase_limit_count"), FreeGachaCampaignId = c.TryGetProperty("free_gacha_campaign_id", out var fc) && fc.ValueKind != JsonValueKind.Null ? GetInt(c, "free_gacha_campaign_id") : (int?)null, CampaignName = c.TryGetProperty("campaign_name", out var cn) && cn.ValueKind == JsonValueKind.String ? cn.GetString() : null, }); } } pack.Banners.Clear(); if (el.TryGetProperty("cardpack_banner_list", out var bl) && bl.ValueKind == JsonValueKind.Array) { foreach (var b in bl.EnumerateArray()) { pack.Banners.Add(new PackBannerEntry { BannerName = GetString(b, "banner_name"), DialogTitle = GetString(b, "dialog_title"), }); } } if (ex is null) { context.Packs.Add(pack); created++; } else updated++; } Console.WriteLine($"[GlobalsImporter] Packs: +{created}/~{updated}"); return created + updated; } // ---------- Practice Opponents ---------- /// /// Capture is the full /practice/info envelope; data is a JSON ARRAY (not an object, /// unlike most endpoints). Each row is one AI opponent row keyed on practice_id. Prod sends /// numeric fields as strings — GetInt tolerates both. Rows present in the DB but missing /// from the capture are LEFT INTACT (consistent with the rest of GlobalsImporter; partial /// captures shouldn't silently delete entries). /// private async Task ImportPracticeOpponents(SVSimDbContext context, JsonElement practiceData) { if (practiceData.ValueKind != JsonValueKind.Array) return 0; var existing = await context.PracticeOpponents.ToDictionaryAsync(e => e.Id); int created = 0, updated = 0; foreach (var row in practiceData.EnumerateArray()) { int practiceId = GetInt(row, "practice_id"); if (practiceId == 0) continue; // malformed row var entry = existing.TryGetValue(practiceId, out var ex) ? ex : new PracticeOpponentEntry { Id = practiceId }; entry.TextId = GetString(row, "text_id"); entry.ClassId = GetInt(row, "class_id"); entry.CharaId = GetInt(row, "chara_id"); entry.DegreeId = GetInt(row, "degree_id"); entry.AiDeckLevel = GetInt(row, "ai_deck_level"); entry.AiLogicLevel = GetInt(row, "ai_logic_level"); entry.AiMaxLife = GetInt(row, "ai_max_life"); entry.Battle3dFieldId = GetString(row, "battle3dfield_id", "1"); entry.IsMaintenance = GetBool(row, "is_maintenance"); entry.IsCampaignPractice = GetBool(row, "is_campaign_practice"); if (ex is null) { context.PracticeOpponents.Add(entry); created++; } else updated++; } Console.WriteLine($"[GlobalsImporter] PracticeOpponents: +{created}/~{updated}"); return created + updated; } // ---------- Basic Puzzle Groups + Puzzles ---------- /// /// /basic_puzzle/info capture is an array of group objects keyed on puzzle_master_id. /// Numeric wire fields come through as strings — GetInt tolerates both. Idempotent upsert /// by puzzle_master_id; rows missing from a partial capture are left intact. /// private async Task ImportPuzzleGroups(SVSimDbContext context, JsonElement infoData) { if (infoData.ValueKind != JsonValueKind.Array) return 0; var existing = await context.PuzzleGroups.ToDictionaryAsync(e => e.Id); int created = 0, updated = 0; foreach (var row in infoData.EnumerateArray()) { int masterId = GetInt(row, "puzzle_master_id"); if (masterId == 0) continue; var entry = existing.TryGetValue(masterId, out var ex) ? ex : new PuzzleGroupEntry { Id = masterId }; entry.BasicTitleTextId = GetString(row, "basic_title_text_id"); entry.PuzzleCharaId = GetInt(row, "puzzle_chara_id"); entry.CharaId = GetInt(row, "chara_id"); entry.SortType = GetInt(row, "sort_type"); entry.DifficultyNameListJson = row.TryGetProperty("puzzle_difficulty_name_list", out var d) ? Serialize(d) : "{}"; if (ex is null) { context.PuzzleGroups.Add(entry); created++; } else updated++; } Console.WriteLine($"[GlobalsImporter] PuzzleGroups: +{created}/~{updated}"); return created + updated; } /// /// Walks each group's puzzle_data array and upserts PuzzleEntry rows keyed on puzzle_id. /// Groups must have been imported first (FK PuzzleEntry.GroupId → PuzzleGroupEntry.Id). /// private async Task ImportPuzzles(SVSimDbContext context, JsonElement infoData) { if (infoData.ValueKind != JsonValueKind.Array) return 0; var existing = await context.Puzzles.ToDictionaryAsync(e => e.Id); int created = 0, updated = 0; foreach (var group in infoData.EnumerateArray()) { int masterId = GetInt(group, "puzzle_master_id"); if (masterId == 0 || !group.TryGetProperty("puzzle_data", out var puzzleArray)) continue; if (puzzleArray.ValueKind != JsonValueKind.Array) continue; foreach (var p in puzzleArray.EnumerateArray()) { int puzzleId = GetInt(p, "puzzle_id"); if (puzzleId == 0) continue; var entry = existing.TryGetValue(puzzleId, out var ex) ? ex : new PuzzleEntry { Id = puzzleId }; entry.GroupId = masterId; entry.PuzzleDifficulty = GetInt(p, "puzzle_difficulty"); entry.IsAdditional = GetBool(p, "is_additional"); entry.IsPlayable = GetBool(p, "is_playable"); entry.ReleaseConditionTextId = GetString(p, "release_condition_text_id"); if (ex is null) { context.Puzzles.Add(entry); created++; } else updated++; } } Console.WriteLine($"[GlobalsImporter] Puzzles: +{created}/~{updated}"); return created + updated; } // ---------- Basic Puzzle Missions ---------- private static readonly Regex RoundMissionPattern = new(@"^Clear all Round (\d+) puzzles$", RegexOptions.Compiled); /// Maps the captured mission_name to its target puzzle_master_id. Returns null for /// Special-Round entries — Phase 1 surfaces them with total_count=0 (see design § Out of Scope). internal static int? DeriveTargetPuzzleGroupId(string missionName) { var m = RoundMissionPattern.Match(missionName); return m.Success ? 300 + int.Parse(m.Groups[1].Value) : null; } private async Task ImportPuzzleMissions(SVSimDbContext context, JsonElement missionData) { if (missionData.ValueKind != JsonValueKind.Array) return 0; // Key by 1-based sequence (the wire has no stable id); first run inserts, re-runs match by index. var existing = await context.PuzzleMissions.ToDictionaryAsync(e => e.Id); int created = 0, updated = 0, unmapped = 0; int seq = 1; foreach (var row in missionData.EnumerateArray()) { string name = GetString(row, "mission_name"); if (string.IsNullOrEmpty(name)) { seq++; continue; } var entry = existing.TryGetValue(seq, out var ex) ? ex : new PuzzleMissionEntry { Id = seq }; entry.MissionName = name; entry.AchievedMessage = RoundMissionPattern.IsMatch(name) ? RoundMissionPattern.Replace(name, m => $"Cleared all Round {m.Groups[1].Value} puzzles") : "Mission achieved"; // Special-Round fallback; only surfaces if a Special mission ever flips, which won't in Phase 1. entry.RequireNumber = GetInt(row, "require_number"); entry.CampaignCommenceTime = GetLong(row, "campaign_commence_time"); entry.OrderId = GetInt(row, "order_id"); // reward_list[0] — single reward per mission. Skip if missing/empty. if (row.TryGetProperty("reward_list", out var rl) && rl.ValueKind == JsonValueKind.Array && rl.GetArrayLength() > 0) { var r = rl[0]; entry.RewardType = GetInt(r, "reward_type"); entry.RewardDetailId = GetLong(r, "reward_detail_id"); entry.RewardNumber = GetInt(r, "reward_number"); } entry.TargetPuzzleGroupId = DeriveTargetPuzzleGroupId(name); if (entry.TargetPuzzleGroupId is null) unmapped++; if (ex is null) { context.PuzzleMissions.Add(entry); created++; } else updated++; seq++; } if (unmapped > 0) Console.WriteLine($"[GlobalsImporter] PuzzleMissions: {unmapped} Special-Round missions left unmapped (Phase 1 deferral)."); Console.WriteLine($"[GlobalsImporter] PuzzleMissions: +{created}/~{updated}"); return created + updated; } // ---------- Helpers ---------- private static void WarnOrphans(string label, int count) { if (count > 0) Console.Error.WriteLine($"[GlobalsImporter] Warning: {label} has {count} orphan card_id(s) — run CardImporter first for clean references."); } }