Seeding updated

This commit is contained in:
gamer147
2026-05-23 16:25:49 -04:00
parent 5f44ee0c7e
commit 56d3cf0ec8
38 changed files with 52689 additions and 62 deletions

View File

@@ -0,0 +1,484 @@
{
"data_headers": {
"sid": "ac631c29b5f5d07ed5fb6712ad8623c31779553958",
"short_udid": 411054851,
"viewer_id": 906243102,
"servertime": 1779553958,
"result_code": 1
},
"data": {
"user_deck_rotation": [],
"user_deck_unlimited": [],
"maintenance_card_list": [],
"user_deck_my_rotation": [],
"trial_deck_list": [],
"default_deck_list": {
"91": {
"null": 1,
"deck_no": 91,
"class_id": 1,
"sleeve_id": 3000011,
"leader_skin_id": 0,
"deck_name": "Default",
"card_id_array": [
100111010,
100111010,
100111010,
100011020,
100011020,
100011020,
100012010,
100111020,
100111020,
100111020,
100111040,
100111040,
100111040,
100114010,
100114010,
100114010,
100011030,
100011030,
100011030,
100111060,
100111060,
100111060,
100011040,
100011040,
100011040,
100111030,
100111030,
100111030,
100111050,
100111050,
100111050,
100011050,
100011050,
100011050,
100111070,
100111070,
100111070,
100121010,
100121010,
100121010
],
"is_complete_deck": 1,
"is_available_deck": 1,
"maintenance_card_ids": []
},
"92": {
"null": 1,
"deck_no": 92,
"class_id": 2,
"sleeve_id": 3000011,
"leader_skin_id": 0,
"deck_name": "Default",
"card_id_array": [
100211010,
100211010,
100211010,
100011020,
100011020,
100011020,
100012010,
100211020,
100211020,
100211020,
100211060,
100211060,
100211060,
100214010,
100214010,
100214010,
100011030,
100011030,
100011030,
100211030,
100211030,
100211030,
100214020,
100214020,
100214020,
100011040,
100011040,
100011040,
100211040,
100211040,
100211040,
100011050,
100011050,
100011050,
100211050,
100211050,
100211050,
100221020,
100221020,
100221020
],
"is_complete_deck": 1,
"is_available_deck": 1,
"maintenance_card_ids": []
},
"93": {
"null": 1,
"deck_no": 93,
"class_id": 3,
"sleeve_id": 3000011,
"leader_skin_id": 0,
"deck_name": "Default",
"card_id_array": [
100314010,
100314010,
100314010,
100011020,
100011020,
100011020,
100012010,
100311010,
100311010,
100311010,
100314030,
100314030,
100314030,
100314020,
100314020,
100314020,
100314040,
100314040,
100314040,
100011030,
100011030,
100011030,
100314050,
100314050,
100314050,
100011040,
100011040,
100011040,
100314060,
100314060,
100314060,
100011050,
100011050,
100011050,
100314070,
100314070,
100314070,
100321010,
100321010,
100321010
],
"is_complete_deck": 1,
"is_available_deck": 1,
"maintenance_card_ids": []
},
"94": {
"null": 1,
"deck_no": 94,
"class_id": 4,
"sleeve_id": 3000011,
"leader_skin_id": 0,
"deck_name": "Default",
"card_id_array": [
100414020,
100414020,
100414020,
100011020,
100011020,
100011020,
100012010,
100411010,
100411010,
100411010,
100414010,
100414010,
100414010,
100011030,
100011030,
100011030,
100411050,
100411050,
100411050,
100011040,
100011040,
100011040,
100411030,
100411030,
100411030,
100414030,
100414030,
100414030,
100011050,
100011050,
100011050,
100411020,
100411020,
100411020,
100411040,
100411040,
100411040,
100421020,
100421020,
100421020
],
"is_complete_deck": 1,
"is_available_deck": 1,
"maintenance_card_ids": []
},
"95": {
"null": 1,
"deck_no": 95,
"class_id": 5,
"sleeve_id": 3000011,
"leader_skin_id": 0,
"deck_name": "Default",
"card_id_array": [
100011020,
100011020,
100011020,
100012010,
100511010,
100511010,
100511010,
100511020,
100511020,
100511020,
100514010,
100514010,
100514010,
100011030,
100011030,
100011030,
100511030,
100511030,
100511030,
100011040,
100011040,
100011040,
100511040,
100511040,
100511040,
100011050,
100011050,
100011050,
100511050,
100511050,
100511050,
100514020,
100514020,
100514020,
100511060,
100511060,
100511060,
100521030,
100521030,
100521030
],
"is_complete_deck": 1,
"is_available_deck": 1,
"maintenance_card_ids": []
},
"96": {
"null": 1,
"deck_no": 96,
"class_id": 6,
"sleeve_id": 3000011,
"leader_skin_id": 0,
"deck_name": "Default",
"card_id_array": [
100011020,
100011020,
100011020,
100012010,
100611010,
100611010,
100611010,
100611020,
100611020,
100611020,
100614010,
100614010,
100614010,
100614020,
100614020,
100614020,
100011030,
100011030,
100011030,
100611030,
100611030,
100611030,
100011040,
100011040,
100011040,
100611050,
100611050,
100611050,
100614030,
100614030,
100614030,
100011050,
100011050,
100011050,
100611040,
100611040,
100611040,
100621010,
100621010,
100621010
],
"is_complete_deck": 1,
"is_available_deck": 1,
"maintenance_card_ids": []
},
"97": {
"null": 1,
"deck_no": 97,
"class_id": 7,
"sleeve_id": 3000011,
"leader_skin_id": 0,
"deck_name": "Default",
"card_id_array": [
100713020,
100713020,
100713020,
100011020,
100011020,
100011020,
100012010,
100713010,
100713010,
100713010,
100711010,
100711010,
100711010,
100714010,
100714010,
100714010,
100714020,
100714020,
100714020,
100011030,
100011030,
100011030,
100713030,
100713030,
100713030,
100011040,
100011040,
100011040,
100011050,
100011050,
100011050,
100723010,
100723010,
100723010,
100714030,
100714030,
100714030,
100711020,
100711020,
100711020
],
"is_complete_deck": 1,
"is_available_deck": 1,
"maintenance_card_ids": []
},
"98": {
"null": 1,
"deck_no": 98,
"class_id": 8,
"sleeve_id": 3000011,
"leader_skin_id": 0,
"deck_name": "Default",
"card_id_array": [
100011020,
100011020,
100011020,
100012010,
100811020,
100811020,
100811020,
100811060,
100811060,
100811060,
100811070,
100811070,
100811070,
100814010,
100814010,
100814010,
100011030,
100011030,
100011030,
100811010,
100811010,
100811010,
100811030,
100811030,
100811030,
100011040,
100011040,
100011040,
100811040,
100811040,
100811040,
100824010,
100824010,
100824010,
100011050,
100011050,
100011050,
100811050,
100811050,
100811050
],
"is_complete_deck": 1,
"is_available_deck": 1,
"maintenance_card_ids": []
}
},
"user_leader_skin_setting_list": {
"1": {
"class_id": 1,
"is_random_leader_skin": 0,
"leader_skin_id": 1
},
"2": {
"class_id": 2,
"is_random_leader_skin": 0,
"leader_skin_id": 2
},
"3": {
"class_id": 3,
"is_random_leader_skin": 0,
"leader_skin_id": 3
},
"4": {
"class_id": 4,
"is_random_leader_skin": 0,
"leader_skin_id": 104
},
"5": {
"class_id": 5,
"is_random_leader_skin": 0,
"leader_skin_id": 5
},
"6": {
"class_id": 6,
"is_random_leader_skin": 0,
"leader_skin_id": 106
},
"7": {
"class_id": 7,
"is_random_leader_skin": 0,
"leader_skin_id": 7
},
"8": {
"class_id": 8,
"is_random_leader_skin": 0,
"leader_skin_id": 8
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,229 @@
{
"data_headers": {
"short_udid": 411054851,
"viewer_id": 906243102,
"sid": "",
"servertime": 1779553959,
"result_code": 1
},
"data": {
"user_info": {
"device_type": "2",
"name": "combusty7",
"country_code": "KOR",
"max_friend": "20",
"last_play_time": "2026-05-23 16:32:39",
"is_received_two_pick_mission": "1",
"birth": "19600101",
"selected_emblem_id": "701441011",
"selected_degree_id": "300003",
"mission_change_time": "2017-09-17 14:47:13",
"mission_receive_type": "0",
"is_official": "0",
"is_official_mark_displayed": "0"
},
"sealed_info": {
"enable": 1,
"crystal_cost": 600,
"rupy_cost": 600,
"ticket_cost": 4,
"is_join": false,
"pack_info": [
10032,
10032,
10031,
10030,
10029
],
"deck_using_num_min": 30,
"schedule_id": 21,
"is_deck_code_maintenance": false,
"sales_period_info": {
"sales_period_series": 33
}
},
"colosseum_info": {
"colosseum_id": "165",
"is_display_tips": "0",
"tips_id": "0",
"card_pool_name": "Take Two (DragonbladeRivenbrandt)",
"is_colosseum_period": true,
"is_round_period": true,
"deck_format": "3",
"is_normal_two_pick": "1",
"is_special_mode": "10",
"is_all_card_enabled": 0,
"start_time": "2026-05-21 06:00:00",
"colosseum_name": "Rivenbrandt Take Two Cup",
"now_round": "1",
"end_time": "2026-05-25 19:59:59",
"sales_period_info": {
"sales_period_time": "2026-05-25 19:59:59"
}
},
"is_available_colosseum_free_entry": true,
"arena_info": [
{
"mode": 1,
"enable": 1,
"cost": 150,
"rupy_cost": 150,
"ticket_cost": 1,
"is_join": false,
"format_info": {
"two_pick_type": "1",
"card_pool_name": "Take Two (DragonbladeRivenbrandt)",
"announce_id": 0,
"last_card_pack_set_id": "10029",
"start_time": "2026-05-01 02:00:00",
"end_time": "2026-06-01 01:59:59"
},
"sales_period_info": {
"sales_period_time": "2026-06-01 01:59:59"
}
}
],
"is_arena_challenge_period": true,
"is_hidden_boss_appeared": false,
"competition_info": {
"is_competition_period": false
},
"treasure_info": null,
"unread_present_count": 0,
"unreceived_mission_reward_count": 0,
"lottery_period_info": null,
"master_point_ranking_period": {
"id": "119",
"period_num": "118",
"necessary_score": "0",
"begin_time": "2026-05-01 02:00:00",
"end_time": "2026-06-01 01:59:59"
},
"last_announce_id": "3353",
"last_announce_update_time": "2026-05-15 10:22:11",
"unfinished_battle_exists": false,
"is_joined_room": false,
"receive_friend_apply_count": 0,
"feature_maintenance_list": [],
"can_give_daily_login_bonus": false,
"friend_battle_invite_count": 0,
"user_config": {
"receive_invitation": "1",
"receive_invitation_in_battle": "1",
"receive_invitation_in_offline": "1",
"receive_friend_apply": "1",
"is_allow_send_adjust": "1",
"is_foil_preferred": "0",
"is_prize_preferred": "0"
},
"banner": [
{
"image_name": "banner_000788",
"click": "account_transition_with_two",
"status": "10",
"change_time": "10",
"remaining_time": "0",
"image_paths": []
},
{
"image_name": "banner_000906",
"click": "colosseum",
"status": "",
"change_time": "10",
"remaining_time": "0",
"image_paths": []
},
{
"image_name": "banner_000220",
"click": "deck_intro_rotation",
"status": "17",
"change_time": "10",
"remaining_time": "0",
"image_paths": []
},
{
"image_name": "banner_000840",
"click": "mission",
"status": "2",
"change_time": "10",
"remaining_time": "0",
"image_paths": []
}
],
"sub_banner": null,
"sub_banner_list": [],
"user_mypage_info": {
"user_mypage_setting": {
"mypage_id": "0",
"select_type": "0",
"mypage_id_list": []
}
},
"user_offline_event": [],
"convention": {
"is_join_tournament": false,
"recent_start_date": null,
"is_admin_watch_user": false
},
"special_crystal_info": [],
"room_type_in_session": {
"special_deck_format_list": [
{
"deck_format": "5",
"end_time": "2030-06-26 19:59:59"
}
]
},
"guild_notification": {
"guild_id": null,
"guild_room_message_id": null,
"is_join_request": false,
"is_invited": false
},
"shop_notification": {
"card_pack": {
"is_open_free_gacha_campaign": false,
"can_free_gacha": false
},
"build_deck": [],
"sleeve": [],
"leader_skin": []
},
"pre_release_status": 0,
"gathering_info": {
"has_invite": 0,
"is_entry": 0
},
"quest": {
"is_open": false,
"is_display_badge": false,
"is_daily_first_access": false,
"end_time": "",
"name": ""
},
"basic_puzzle": {
"is_display_badge": true
},
"all_card_enabled_period": null,
"user_item_list": [
{
"item_id": "1",
"number": "19"
},
{
"item_id": "10011",
"number": "1"
},
{
"item_id": "80001",
"number": "1"
}
],
"is_battle_pass_period": true,
"story_notification": {
"is_display_ribbon": false,
"is_display_badge": false
},
"home_dialog_list": []
}
}

View File

@@ -0,0 +1,148 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Reads the loader's card dump (LitJson array of CardCSVData) and upserts ShadowverseCardEntry +
/// ShadowverseCardSetEntry rows. Lifted unchanged from the original SVSim.CardImport.Program.Main —
/// only the orchestration was moved into <see cref="Program"/>.
/// </summary>
public class CardImporter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
public async Task<int> ImportAsync(SVSimDbContext context, string cardsJsonPath)
{
if (!File.Exists(cardsJsonPath))
{
Console.Error.WriteLine($"[CardImporter] cards.json not found at {cardsJsonPath}; skipping card import.");
return 0;
}
Console.WriteLine($"[CardImporter] Reading {cardsJsonPath} ({new FileInfo(cardsJsonPath).Length / 1024} KiB)...");
List<CardInput>? input;
await using (var fs = File.OpenRead(cardsJsonPath))
{
input = await JsonSerializer.DeserializeAsync<List<CardInput>>(fs, JsonOptions);
}
if (input is null || input.Count == 0)
{
Console.Error.WriteLine("[CardImporter] No card records parsed from input.");
return 0;
}
Console.WriteLine($"[CardImporter] Parsed {input.Count} card records.");
var classesById = await context.Classes.ToDictionaryAsync(c => c.Id);
var existingSets = (await context.CardSets.ToListAsync()).ToDictionary(s => s.Id);
var existingCards = (await context.Cards.ToListAsync()).ToDictionary(c => c.Id);
Console.WriteLine(
$"[CardImporter] DB state before: {existingCards.Count} cards, {existingSets.Count} card sets, " +
$"{classesById.Count} classes seeded.");
int created = 0, updated = 0, skipped = 0, setsCreated = 0;
foreach (var c in input)
{
if (!long.TryParse(c.CardId, out long id) || id == 0)
{
skipped++;
continue;
}
int setId = ParseInt(c.CardSetId, 0);
int clan = ParseInt(c.Clan, 0);
int rarity = ParseInt(c.Rarity, 0);
if (!existingSets.TryGetValue(setId, out var set))
{
set = new ShadowverseCardSetEntry
{
Id = setId,
Name = $"Card Set {setId}",
IsInRotation = true,
IsBasic = false
};
context.CardSets.Add(set);
existingSets[setId] = set;
setsCreated++;
}
ClassEntry? classEntry = clan > 0 && classesById.TryGetValue(clan, out var ce) ? ce : null;
var collection = new CardCollectionInfo
{
CraftCost = ParseInt(c.UseRedEther, 0),
DustReward = ParseInt(c.GetRedEther, 0)
};
if (existingCards.TryGetValue(id, out var card))
{
card.Rarity = (Rarity)rarity;
card.PrimaryResourceCost = ParseNullableInt(c.Cost);
card.Attack = ParseNullableInt(c.Atk);
card.Defense = ParseNullableInt(c.Life);
card.Class = classEntry;
card.CollectionInfo = collection;
updated++;
}
else
{
card = new ShadowverseCardEntry
{
Id = id,
Name = $"Card {id}",
Rarity = (Rarity)rarity,
PrimaryResourceCost = ParseNullableInt(c.Cost),
Attack = ParseNullableInt(c.Atk),
Defense = ParseNullableInt(c.Life),
Class = classEntry,
CollectionInfo = collection
};
set.Cards.Add(card);
existingCards[id] = card;
created++;
}
}
Console.WriteLine(
$"[CardImporter] Saving: +{created} cards, ~{updated} updated, +{setsCreated} card sets, " +
$"skipped {skipped} (bad/missing card_id)...");
await context.SaveChangesAsync();
Console.WriteLine("[CardImporter] Done.");
return created + updated;
}
private static int ParseInt(string? raw, int fallback) =>
int.TryParse(raw, out int v) ? v : fallback;
private static int? ParseNullableInt(string? raw) =>
int.TryParse(raw, out int v) ? v : null;
}
/// <summary>
/// Lightweight projection over the CardCSVData fields we care about. The dump has many more
/// fields (PascalCase metadata + effect/voice/visual paths) — we ignore them; only the
/// snake_case CSV columns map here via the SnakeCaseLower naming policy.
/// </summary>
public class CardInput
{
public string? CardId { get; set; }
public string? CardSetId { get; set; }
public string? Clan { get; set; }
public string? Cost { get; set; }
public string? Atk { get; set; }
public string? Life { get; set; }
public string? Rarity { get; set; }
public string? GetRedEther { get; set; }
public string? UseRedEther { get; set; }
}

View File

@@ -0,0 +1,620 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Models;
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");
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);
}
if (deckInfo.HasValue)
{
total += await ImportDefaultDecks(context, deckInfo.Value);
total += await ImportDefaultLeaderSkinSettings(context, deckInfo.Value);
}
await context.SaveChangesAsync();
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
return total;
}
// ---------- GameConfiguration ----------
private async Task<int> ImportGameConfigurationExtensions(SVSimDbContext context, JsonElement loadIndex)
{
var cfg = await context.GameConfigurations.FirstOrDefaultAsync(g => g.Id == "default");
if (cfg is null)
{
Console.Error.WriteLine("[GlobalsImporter] GameConfigurations 'default' row missing; " +
"DefaultSettingsSeeder should have created it. Skipping extensions.");
return 0;
}
cfg.TsRotationId = GetString(loadIndex, "ts_rotation_id");
cfg.IsBattlePassPeriod = GetBool(loadIndex, "is_battle_pass_period");
cfg.IsBeginnerMission = GetBool(loadIndex, "is_beginner_mission");
cfg.CardSetIdForResourceDlView = GetInt(loadIndex, "card_set_id_for_resource_dl_view");
if (loadIndex.TryGetProperty("challenge_config", out var cc))
{
cfg.ChallengeUseTwoPickPremiumCard = GetBool(cc, "use_challenge_two_pick_premium_card");
cfg.ChallengeTwoPickSleeveId = GetLong(cc, "challenge_two_pick_sleeve_id");
}
Console.WriteLine($"[GlobalsImporter] GameConfiguration extensions: ts_rotation_id={cfg.TsRotationId}");
return 1;
}
// ---------- 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;
}
// ---------- 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;
}
// ---------- 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.");
}
}

View File

@@ -0,0 +1,140 @@
using System.Text.Json;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Shared helpers for content importers. Loads a prod-capture JSON file by endpoint name from
/// a captures directory, returning the inner <c>data</c> element. Picks the latest matching dated
/// file (e.g. <c>load-index-2026-05-23.json</c>) if multiple exist for the same endpoint.
/// </summary>
public static class ImporterBase
{
/// <summary>
/// Returns the parsed <c>.data</c> JsonElement for the latest <c>{endpoint}-*.json</c> file in
/// <paramref name="capturesDir"/>, or null if no file matches. Logs a warning when missing —
/// caller decides whether absence is fatal.
/// </summary>
public static JsonElement? LoadCapture(string capturesDir, string endpoint)
{
if (!Directory.Exists(capturesDir))
{
Console.Error.WriteLine($"[ImporterBase] Captures dir missing: {capturesDir}");
return null;
}
string? path = Directory.EnumerateFiles(capturesDir, $"{endpoint}-*.json")
.OrderByDescending(p => p)
.FirstOrDefault();
if (path is null)
{
Console.Error.WriteLine($"[ImporterBase] No capture found for endpoint '{endpoint}' in {capturesDir}");
return null;
}
using var fs = File.OpenRead(path);
using var doc = JsonDocument.Parse(fs);
if (!doc.RootElement.TryGetProperty("data", out var data))
{
Console.Error.WriteLine($"[ImporterBase] Capture file {path} has no top-level 'data' property.");
return null;
}
// Clone so the JsonElement survives doc disposal.
return data.Clone();
}
/// <summary>
/// Generic upsert by primary key. Returns (created, updated, unchanged) counts.
/// <paramref name="incoming"/> is the desired state from the capture; rows are matched by
/// <paramref name="keySelector"/>. <paramref name="applyChanges"/> mutates an existing row to
/// reflect incoming values and returns true if anything actually changed.
/// </summary>
public static (int created, int updated, int unchanged) Upsert<T, TKey>(
IEnumerable<T> incoming,
Dictionary<TKey, T> existingByKey,
Func<T, TKey> keySelector,
Action<T> addToContext,
Func<T, T, bool> applyChanges) where TKey : notnull
{
int created = 0, updated = 0, unchanged = 0;
foreach (var item in incoming)
{
var key = keySelector(item);
if (existingByKey.TryGetValue(key, out var existing))
{
if (applyChanges(existing, item)) updated++;
else unchanged++;
}
else
{
addToContext(item);
existingByKey[key] = item;
created++;
}
}
return (created, updated, unchanged);
}
/// <summary>Serialize a JsonElement back to compact JSON text for jsonb storage.</summary>
public static string Serialize(JsonElement el) =>
JsonSerializer.Serialize(el, new JsonSerializerOptions { WriteIndented = false });
/// <summary>Parse a wire date that may be ISO ("2026-05-23T..."), space-separated ("2026-05-23 16:32:31"), or empty.</summary>
public static DateTime ParseWireDateTime(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return DateTime.MinValue;
if (DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out var dt))
{
return dt;
}
return DateTime.MinValue;
}
/// <summary>Read a JsonElement string/number property as long, defaulting on missing/null.</summary>
public static long GetLong(JsonElement el, string prop, long fallback = 0)
{
if (!el.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return fallback;
if (v.ValueKind == JsonValueKind.Number) return v.GetInt64();
if (v.ValueKind == JsonValueKind.String && long.TryParse(v.GetString(), out var n)) return n;
return fallback;
}
public static int GetInt(JsonElement el, string prop, int fallback = 0)
{
if (!el.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return fallback;
if (v.ValueKind == JsonValueKind.Number) return v.GetInt32();
if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var n)) return n;
return fallback;
}
public static string GetString(JsonElement el, string prop, string fallback = "")
{
if (!el.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return fallback;
return v.ValueKind == JsonValueKind.String ? v.GetString() ?? fallback : v.ToString();
}
public static bool GetBool(JsonElement el, string prop, bool fallback = false)
{
if (!el.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return fallback;
if (v.ValueKind == JsonValueKind.True) return true;
if (v.ValueKind == JsonValueKind.False) return false;
if (v.ValueKind == JsonValueKind.Number) return v.GetInt32() != 0;
if (v.ValueKind == JsonValueKind.String)
{
var s = v.GetString();
if (bool.TryParse(s, out var b)) return b;
if (int.TryParse(s, out var i)) return i != 0;
}
return fallback;
}
public static ulong GetULong(JsonElement el, string prop, ulong fallback = 0)
{
if (!el.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return fallback;
if (v.ValueKind == JsonValueKind.Number) return v.GetUInt64();
if (v.ValueKind == JsonValueKind.String && ulong.TryParse(v.GetString(), out var n)) return n;
return fallback;
}
}

161
SVSim.Bootstrap/Program.cs Normal file
View File

@@ -0,0 +1,161 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
namespace SVSim.Bootstrap;
public static class Program
{
private const string DefaultConnectionString =
"Host=localhost;Database=svsim;Username=postgres;password=postgres";
public static async Task<int> Main(string[] args)
{
if (args.Length > 0 && (args[0] is "--help" or "-h"))
{
PrintUsage();
return 1;
}
var opts = ParseArgs(args);
if (opts is null)
{
PrintUsage();
return 1;
}
if (opts.SkipCards && opts.SkipGlobals)
{
Console.Error.WriteLine("Both --skip-cards and --skip-globals set; nothing to do.");
return 1;
}
Console.WriteLine($"[Bootstrap] Connection: {RedactPassword(opts.ConnectionString)}");
Console.WriteLine($"[Bootstrap] Cards file: {opts.CardsFile}");
Console.WriteLine($"[Bootstrap] Captures: {opts.CapturesDir}");
var dbOptions = new DbContextOptionsBuilder<SVSimDbContext>()
.UseNpgsql(opts.ConnectionString)
.Options;
await using var context = new SVSimDbContext(NullLogger<SVSimDbContext>.Instance, dbOptions);
// Bootstrap applies pending migrations first so it can be the very first thing run after
// `dotnet ef migrations add` — no need to start the server too.
Console.WriteLine("[Bootstrap] Applying pending migrations...");
await context.Database.MigrateAsync();
if (!opts.SkipCards)
{
await new CardImporter().ImportAsync(context, opts.CardsFile);
}
else
{
Console.WriteLine("[Bootstrap] --skip-cards set; skipping card import.");
}
if (!opts.SkipGlobals)
{
await new GlobalsImporter().ImportAllAsync(context, opts.CapturesDir);
}
else
{
Console.WriteLine("[Bootstrap] --skip-globals set; skipping globals import.");
}
Console.WriteLine("[Bootstrap] Complete.");
return 0;
}
private static BootstrapOptions? ParseArgs(string[] args)
{
string? dataDir = null;
string? cards = null;
string? captures = null;
string? connection = null;
bool skipCards = false;
bool skipGlobals = false;
string? positionalCards = null;
for (int i = 0; i < args.Length; i++)
{
string a = args[i];
switch (a)
{
case "--data-dir": dataDir = NextArg(args, ref i); break;
case "--cards": cards = NextArg(args, ref i); break;
case "--captures": captures = NextArg(args, ref i); break;
case "--connection-string": connection = NextArg(args, ref i); break;
case "--skip-cards": skipCards = true; break;
case "--skip-globals": skipGlobals = true; break;
default:
// Back-compat: legacy positional form `svsim-card-import <cards.json> [connection]`.
if (positionalCards is null && !a.StartsWith('-')) positionalCards = a;
else if (connection is null && !a.StartsWith('-')) connection = a;
else { Console.Error.WriteLine($"Unknown argument: {a}"); return null; }
break;
}
}
// Resolution order:
// --cards beats --data-dir/cards.json beats legacy positional;
// --captures beats --data-dir/prod-captures beats Bootstrap/Data/prod-captures (shipped default).
string baseDir = AppContext.BaseDirectory;
string shippedCaptures = Path.Combine(baseDir, "Data", "prod-captures");
string cardsFile = cards
?? (dataDir is not null ? Path.Combine(dataDir, "cards.json") : null)
?? positionalCards
?? "data_dumps/cards.json";
// Resolve captures dir, falling back to the shipped copy if the data-dir path is unset
// OR points at a missing folder. (Common case: user has cards.json in data_dumps/ but
// hasn't copied prod-captures/ there — the shipped snapshot is the source of truth.)
string? capturesCandidate = captures
?? (dataDir is not null ? Path.Combine(dataDir, "prod-captures") : null);
string capturesDir = capturesCandidate is not null && Directory.Exists(capturesCandidate)
? capturesCandidate
: shippedCaptures;
string connStr = connection
?? Environment.GetEnvironmentVariable("NPGSQL_CONNECTION")
?? DefaultConnectionString;
return new BootstrapOptions(cardsFile, capturesDir, connStr, skipCards, skipGlobals);
}
private static string NextArg(string[] args, ref int i)
{
if (i + 1 >= args.Length) throw new ArgumentException($"Missing value for {args[i]}");
return args[++i];
}
private static string RedactPassword(string conn) =>
System.Text.RegularExpressions.Regex.Replace(conn, "(?i)(password=)[^;]+", "$1***");
private static void PrintUsage()
{
Console.Error.WriteLine(
"Usage: svsim-bootstrap [options]\n" +
"\n" +
" --data-dir <path> Directory containing cards.json and prod-captures/\n" +
" (default: ./data_dumps relative to working dir)\n" +
" --cards <file> Override path to cards.json\n" +
" --captures <dir> Override path to prod-captures directory\n" +
" (default: shipped Data/prod-captures next to the binary)\n" +
" --connection-string <conn> Postgres connection (or NPGSQL_CONNECTION env var,\n" +
$" then \"{DefaultConnectionString}\")\n" +
" --skip-cards Skip card import (re-run globals only)\n" +
" --skip-globals Skip globals import (cards only — legacy behavior)\n" +
"\n" +
"Back-compat: `svsim-bootstrap <cards.json> [connection]` still works (positional).");
}
private sealed record BootstrapOptions(
string CardsFile,
string CapturesDir,
string ConnectionString,
bool SkipCards,
bool SkipGlobals);
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>SVSim.Bootstrap</RootNamespace>
<AssemblyName>svsim-bootstrap</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Content Include="Data\prod-captures\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SVSim.Database\SVSim.Database.csproj" />
</ItemGroup>
</Project>