Files
SVSimServer/SVSim.Bootstrap/Importers/GlobalsImporter.cs
2026-05-24 21:13:15 -04:00

939 lines
45 KiB
C#

using System.Text.Json;
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;
/// <summary>
/// Imports prod-captured globals from <c>{capturesDir}/{endpoint}-*.json</c> snapshots into the
/// DB via idempotent upserts. Source endpoints: <c>load-index</c>, <c>mypage-index</c>, <c>deck-info</c>.
///
/// 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.
/// </summary>
public class GlobalsImporter
{
public async Task<int> 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");
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);
}
await context.SaveChangesAsync();
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
return total;
}
// ---------- GameConfig sections ----------
private async Task<int> 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<RotationConfig>(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<ChallengeConfig>(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<MyRotationScheduleConfig>(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<T>(SVSimDbContext context, Func<T> shippedDefaults, Action<T> mutate)
where T : class, new()
{
var sectionName = typeof(T).GetCustomAttributes(typeof(ConfigSectionAttribute), inherit: false)
.Cast<ConfigSectionAttribute>().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<T>(row.ValueJson) ?? shippedDefaults();
}
mutate(value);
row.ValueJson = JsonSerializer.Serialize(value);
}
// ---------- My Rotation ----------
private async Task<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<long>(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<int> 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<long>(await context.Cards.Select(c => c.Id).ToListAsync());
int created = 0, orphans = 0;
IEnumerable<long> 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<int> 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<long>(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<int> 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<long>(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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<long>(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<int> 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<int> 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 ----------
/// <summary>
/// Imports /pack/info's pack_config_list into PackConfigEntry rows. The capture's <c>data</c>
/// element wraps an object with a <c>pack_config_list</c> 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.
/// </summary>
private async Task<int> 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 ----------
/// <summary>
/// Capture is the full /practice/info envelope; <c>data</c> 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).
/// </summary>
private async Task<int> 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;
}
// ---------- 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.");
}
}