refactor(bootstrap): finalize load-index migration; GlobalsImporter is now a stub

Stage 9C of the bootstrap-seed-refactor:

- Add 6 seed DTOs for the card-id-keyed load-index tables (SpotCard,
  ReprintedCard, UnlimitedRestriction, LoadingExclusionCard, MaintenanceCard,
  FeatureMaintenance).
- Add CardListsImporter: idempotent upsert of the 6 tables, sharing one
  Cards FK set for orphan-warning. FeatureMaintenances clear-and-rewrites
  (synthetic ordinal Id; no natural key).
- Add RotationFlagUpdater: reads RotationConfig.RotationCardSetIds from the
  GameConfigs section (populated by RotationConfigImporter) and flips
  CardSet.IsInRotation to match.
- Add RotationConfig.RotationCardSetIds list property + wire it through
  RotationConfigImporter. No migration needed (sections are JSON blobs).
- RotationConfigImporter: use legacy local-kind DateTime parse for schedule
  windows so the JSON round-trip stays byte-equivalent to GlobalsImporter.
- Strip GlobalsImporter down to a no-op stub (Task 10 will delete it).
- Wire all 9 new importers into Program.cs and SVSimTestFactory.SeedGlobalsAsync,
  in the order RotationConfigImporter -> ... -> CardListsImporter -> RotationFlagUpdater.
- Delete prod-captures/load-index-2026-05-23.json.
- Add CardListsImporterTests covering each sub-table, idempotency,
  empty-seed handling, orphan-warning, and the clear-and-rewrite path.

Tests: 391 passing (382 baseline + 9 new).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-26 15:46:36 -04:00
parent 87d0001569
commit d14a0be2c8
15 changed files with 550 additions and 15057 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of the six card-id-keyed tables from load-index seeds:
/// SpotCards, ReprintedCards, UnlimitedRestrictions, LoadingExclusionCards,
/// MaintenanceCards, FeatureMaintenances. Loads the Cards FK set once for orphan warnings.
/// Rows missing from a seed are LEFT INTACT (consistent with prior GlobalsImporter behavior)
/// for the five card-id-keyed tables; FeatureMaintenances clears-and-rewrites because its
/// synthetic ordinal Id has no natural-key semantics.
/// </summary>
public class CardListsImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var knownCards = new HashSet<long>(await context.Cards.Select(c => c.Id).ToListAsync());
int total = 0;
total += await ImportSpot(context, seedDir, knownCards);
total += await ImportReprinted(context, seedDir, knownCards);
total += await ImportUnlimited(context, seedDir, knownCards);
total += await ImportLoadingExclusion(context, seedDir, knownCards);
total += await ImportMaintenance(context, seedDir);
total += await ImportFeatureMaintenances(context, seedDir);
await context.SaveChangesAsync();
return total;
}
private async Task<int> ImportSpot(SVSimDbContext context, string seedDir, HashSet<long> knownCards)
{
var seed = SeedLoader.LoadList<SpotCardSeed>(Path.Combine(seedDir, "spot-cards.json"));
if (seed.Count == 0) return 0;
var existing = await context.SpotCards.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0, orphans = 0;
foreach (var s in seed)
{
if (s.CardId == 0) continue;
if (!knownCards.Contains(s.CardId)) orphans++;
var entry = existing.TryGetValue(s.CardId, out var ex) ? ex : new SpotCardEntry { Id = s.CardId };
entry.Cost = s.Cost;
if (ex is null) { context.SpotCards.Add(entry); existing[s.CardId] = entry; created++; }
else updated++;
}
WarnOrphans("SpotCards", orphans);
Console.WriteLine($"[CardListsImporter] SpotCards +{created}/~{updated}");
return created + updated;
}
private async Task<int> ImportReprinted(SVSimDbContext context, string seedDir, HashSet<long> knownCards)
{
var seed = SeedLoader.LoadList<ReprintedCardSeed>(Path.Combine(seedDir, "reprinted-cards.json"));
if (seed.Count == 0) return 0;
var existing = await context.ReprintedCards.ToDictionaryAsync(e => e.Id);
int created = 0, orphans = 0;
foreach (var s in seed)
{
if (s.CardId == 0) continue;
if (!knownCards.Contains(s.CardId)) orphans++;
if (existing.ContainsKey(s.CardId)) continue;
var entry = new ReprintedCardEntry { Id = s.CardId };
context.ReprintedCards.Add(entry);
existing[s.CardId] = entry;
created++;
}
WarnOrphans("ReprintedCards", orphans);
Console.WriteLine($"[CardListsImporter] ReprintedCards +{created}");
return created;
}
private async Task<int> ImportUnlimited(SVSimDbContext context, string seedDir, HashSet<long> knownCards)
{
var seed = SeedLoader.LoadList<UnlimitedRestrictionSeed>(Path.Combine(seedDir, "unlimited-restrictions.json"));
if (seed.Count == 0) return 0;
var existing = await context.UnlimitedRestrictions.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0, orphans = 0;
foreach (var s in seed)
{
if (s.CardId == 0) continue;
if (!knownCards.Contains(s.CardId)) orphans++;
var entry = existing.TryGetValue(s.CardId, out var ex) ? ex : new UnlimitedRestrictionEntry { Id = s.CardId };
entry.RestrictionValue = s.RestrictionValue;
if (ex is null) { context.UnlimitedRestrictions.Add(entry); existing[s.CardId] = entry; created++; }
else updated++;
}
WarnOrphans("UnlimitedRestrictions", orphans);
Console.WriteLine($"[CardListsImporter] UnlimitedRestrictions +{created}/~{updated}");
return created + updated;
}
private async Task<int> ImportLoadingExclusion(SVSimDbContext context, string seedDir, HashSet<long> knownCards)
{
var seed = SeedLoader.LoadList<LoadingExclusionCardSeed>(Path.Combine(seedDir, "loading-exclusion-cards.json"));
if (seed.Count == 0) return 0;
var existing = await context.LoadingExclusionCards.ToDictionaryAsync(e => e.Id);
int created = 0, orphans = 0;
foreach (var s in seed)
{
if (s.CardId == 0) continue;
if (!knownCards.Contains(s.CardId)) orphans++;
if (existing.ContainsKey(s.CardId)) continue;
var entry = new LoadingExclusionCardEntry { Id = s.CardId };
context.LoadingExclusionCards.Add(entry);
existing[s.CardId] = entry;
created++;
}
WarnOrphans("LoadingExclusionCards", orphans);
Console.WriteLine($"[CardListsImporter] LoadingExclusionCards +{created}");
return created;
}
private async Task<int> ImportMaintenance(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<MaintenanceCardSeed>(Path.Combine(seedDir, "maintenance-cards.json"));
if (seed.Count == 0) return 0;
var existing = await context.MaintenanceCards.ToDictionaryAsync(e => e.Id);
int created = 0;
foreach (var s in seed)
{
if (s.CardId == 0) continue;
if (existing.ContainsKey(s.CardId)) continue;
var entry = new MaintenanceCardEntry { Id = s.CardId };
context.MaintenanceCards.Add(entry);
existing[s.CardId] = entry;
created++;
}
Console.WriteLine($"[CardListsImporter] MaintenanceCards +{created}");
return created;
}
private async Task<int> ImportFeatureMaintenances(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<FeatureMaintenanceSeed>(Path.Combine(seedDir, "feature-maintenances.json"));
if (seed.Count == 0) return 0;
// FeatureMaintenances has a synthetic int Id assigned by the extractor (1-based ordinal).
// The original GlobalsImporter.ImportFeatureMaintenances added rows without dedup; since the
// seed is regenerated on every extract, clear-and-rewrite keeps re-runs idempotent and
// matches "the latest seed is authoritative". Pre-existing rows with seed-absent ids are
// dropped here (acceptable: only synthetic ordinals, no FKs reference this table).
var existing = await context.FeatureMaintenances.ToListAsync();
context.FeatureMaintenances.RemoveRange(existing);
int created = 0;
foreach (var s in seed)
{
if (s.Id == 0) continue;
context.FeatureMaintenances.Add(new FeatureMaintenanceEntry
{
Id = s.Id,
FeatureKey = s.FeatureKey,
Data = s.Data.ValueKind == JsonValueKind.Undefined ? "{}" : JsonSerializer.Serialize(s.Data),
});
created++;
}
Console.WriteLine($"[CardListsImporter] FeatureMaintenances: -{existing.Count}/+{created}");
return created;
}
private static void WarnOrphans(string label, int count)
{
if (count > 0)
Console.Error.WriteLine($"[CardListsImporter] Warning: {label} has {count} orphan card_id(s) — run CardImporter first for clean references.");
}
}

View File

@@ -1,508 +1,22 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
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>. Per-endpoint seed-file
/// importers (DefaultDeckImporter, PackImporter, MyPageGlobalsImporter, etc.) cover the rest.
///
/// 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.
/// Stub remaining after Stage 9C: the entire load-index → DB pipeline has been replaced by
/// per-domain importers in this folder (RotationConfigImporter, MyRotationImporter,
/// AvatarAbilityImporter, ArenaSeasonImporter, BattlePassImporter, DailyLoginBonusImporter,
/// PreReleaseInfoImporter, CardListsImporter, RotationFlagUpdater). Task 10 will delete this
/// class entirely; until then this stub keeps existing call sites compiling.
/// </summary>
public class GlobalsImporter
{
public async Task<int> ImportAllAsync(SVSimDbContext context, string capturesDir)
public Task<int> ImportAllAsync(SVSimDbContext context, string capturesDir)
{
Console.WriteLine($"[GlobalsImporter] Loading captures from {capturesDir}...");
JsonElement? loadIndex = LoadCapture(capturesDir, "load-index");
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);
}
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;
}
// ---------- 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.");
// All work migrated to per-domain importers wired in Program.cs and
// SVSimTestFactory.SeedGlobalsAsync. Intentionally a no-op.
_ = context;
_ = capturesDir;
return Task.FromResult(0);
}
}

View File

@@ -30,6 +30,7 @@ public class RotationConfigImporter
c.IsBattlePassPeriod = rot.IsBattlePassPeriod;
c.IsBeginnerMission = rot.IsBeginnerMission;
c.CardSetIdForResourceDlView = rot.CardSetIdForResourceDlView;
c.RotationCardSetIds = rot.RotationCardSetIds ?? new List<int>();
});
touched++;
}
@@ -48,10 +49,15 @@ public class RotationConfigImporter
var schedule = SeedLoader.LoadObject<MyRotationScheduleSeed>(Path.Combine(seedDir, "my-rotation-schedule.json"));
if (schedule?.Gathering is not null && schedule.FreeBattle is not null)
{
var gBegin = ParseWireDateTime(schedule.Gathering.Begin);
var gEnd = ParseWireDateTime(schedule.Gathering.End);
var fBegin = ParseWireDateTime(schedule.FreeBattle.Begin);
var fEnd = ParseWireDateTime(schedule.FreeBattle.End);
// Schedule windows are intentionally parsed WITHOUT AssumeUniversal because the seed
// strings ("2024-05-01 20:00:00") are timezone-less and the rest of the pipeline (the
// [ConfigSection] JSON round-trip + LoadController's wire mapping) treats them as
// local-kind ticks. Mirrors the legacy GlobalsImporter.TryParseScheduleWindow behavior
// — see GlobalsRepositoryTests for the round-trip assertion.
var gBegin = ParseScheduleWireDateTime(schedule.Gathering.Begin);
var gEnd = ParseScheduleWireDateTime(schedule.Gathering.End);
var fBegin = ParseScheduleWireDateTime(schedule.FreeBattle.Begin);
var fEnd = ParseScheduleWireDateTime(schedule.FreeBattle.End);
// Only commit when both windows parsed to real DateTimes — a malformed/0001 value
// would silently lock the MyRotation feature off (the original bug the section fixed).
if (gBegin != DateTime.MinValue && gEnd != DateTime.MinValue
@@ -75,6 +81,15 @@ public class RotationConfigImporter
return touched;
}
// Legacy schedule-window parse: default styles (AssumeLocal), matching the original
// GlobalsImporter.TryParseScheduleWindow. The schedule strings are timezone-less; preserving
// legacy local-kind ticks keeps the wire output byte-equivalent across the migration.
private static DateTime ParseScheduleWireDateTime(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return DateTime.MinValue;
return DateTime.TryParse(s, out var dt) ? dt : DateTime.MinValue;
}
// Verbatim copy of GlobalsImporter.UpsertSection<T>. Kept private-static here so this
// importer can stand alone after Stage 9C strips the GlobalsImporter copy.
private static async Task UpsertSection<T>(SVSimDbContext context, Func<T> shippedDefaults, Action<T> mutate)

View File

@@ -0,0 +1,64 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Models.Config;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Reads <see cref="RotationConfig"/> from the GameConfigs table (populated by
/// <see cref="RotationConfigImporter"/>) and flips <c>CardSet.IsInRotation</c> to match.
/// Must run after RotationConfigImporter and CardImporter — CardSets missing from the DB
/// can't be promoted (the original GlobalsImporter behavior; we log a warning instead of failing).
/// </summary>
public class RotationFlagUpdater
{
public async Task<int> UpdateAsync(SVSimDbContext context)
{
var sectionName = typeof(RotationConfig).GetCustomAttributes(typeof(ConfigSectionAttribute), inherit: false)
.Cast<ConfigSectionAttribute>().FirstOrDefault()?.Name
?? throw new InvalidOperationException("RotationConfig missing [ConfigSection]");
var row = await context.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == sectionName);
if (row is null)
{
Console.WriteLine("[RotationFlagUpdater] No Rotation section in GameConfigs; skipping.");
return 0;
}
var cfg = JsonSerializer.Deserialize<RotationConfig>(row.ValueJson);
if (cfg is null)
{
Console.WriteLine("[RotationFlagUpdater] Failed to deserialize RotationConfig; skipping.");
return 0;
}
var rotationSet = (cfg.RotationCardSetIds ?? new List<int>()).ToHashSet();
if (rotationSet.Count == 0)
{
Console.WriteLine("[RotationFlagUpdater] RotationCardSetIds empty; no flag changes.");
return 0;
}
var allSets = await context.CardSets.ToListAsync();
int updated = 0, missing = 0;
foreach (var rid in rotationSet)
{
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 && !rotationSet.Contains(s.Id)))
{
s.IsInRotation = false;
updated++;
}
if (missing > 0)
Console.Error.WriteLine($"[RotationFlagUpdater] Warning: {missing} rotation card_set_id(s) missing from CardSets — run CardImporter first.");
await context.SaveChangesAsync();
Console.WriteLine($"[RotationFlagUpdater] CardSet.IsInRotation ~{updated}");
return updated;
}
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
/// <summary>
/// Mirrors one entry of <c>seeds/feature-maintenances.json</c>. Source: <c>/load/index
/// data.feature_maintenance_list</c> (array of dicts; usually empty). <see cref="Data"/> is
/// the raw element so it round-trips verbatim into the entity's jsonb column.
/// </summary>
public sealed class FeatureMaintenanceSeed
{
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("feature_key")] public string FeatureKey { get; set; } = "";
[JsonPropertyName("data")] public JsonElement Data { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
/// <summary>
/// Mirrors one entry of <c>seeds/loading-exclusion-cards.json</c>. Source: <c>/load/index
/// data.loading_exclusion_card_list</c> (array of card_ids).
/// </summary>
public sealed class LoadingExclusionCardSeed
{
[JsonPropertyName("card_id")] public long CardId { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
/// <summary>
/// Mirrors one entry of <c>seeds/maintenance-cards.json</c>. Source: <c>/load/index
/// data.maintenance_card_list</c> (array of card_ids; usually empty).
/// </summary>
public sealed class MaintenanceCardSeed
{
[JsonPropertyName("card_id")] public long CardId { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
/// <summary>
/// Mirrors one entry of <c>seeds/reprinted-cards.json</c>. Source: <c>/load/index
/// data.reprinted_base_card_ids</c> (dict or list of card_ids).
/// </summary>
public sealed class ReprintedCardSeed
{
[JsonPropertyName("card_id")] public long CardId { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
/// <summary>
/// Mirrors one entry of <c>seeds/spot-cards.json</c>. Source: <c>/load/index data.spot_cards</c>
/// — extractor reshapes the wire dict {card_id: cost} into a list of {card_id, cost} rows.
/// </summary>
public sealed class SpotCardSeed
{
[JsonPropertyName("card_id")] public long CardId { get; set; }
[JsonPropertyName("cost")] public int Cost { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
/// <summary>
/// Mirrors one entry of <c>seeds/unlimited-restrictions.json</c>. Source: <c>/load/index
/// data.unlimited_restricted_base_card_id_list</c> (dict {card_id: restriction_value}).
/// </summary>
public sealed class UnlimitedRestrictionSeed
{
[JsonPropertyName("card_id")] public long CardId { get; set; }
[JsonPropertyName("restriction_value")] public int RestrictionValue { get; set; }
}

View File

@@ -76,6 +76,20 @@ public static class Program
if (!opts.SkipGlobals)
{
await new GlobalsImporter().ImportAllAsync(context, opts.CapturesDir);
// Load-index seed pipeline (Stage 9C replaced the old in-GlobalsImporter capture-parsing).
// RotationConfigImporter writes the Rotation GameConfig section that RotationFlagUpdater
// reads; CardImporter ran earlier in the !SkipCards block so CardSets are populated.
await new RotationConfigImporter().ImportAsync(context, opts.SeedDir);
await new MyRotationImporter().ImportAsync(context, opts.SeedDir);
await new AvatarAbilityImporter().ImportAsync(context, opts.SeedDir);
await new ArenaSeasonImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassImporter().ImportAsync(context, opts.SeedDir);
await new DailyLoginBonusImporter().ImportAsync(context, opts.SeedDir);
await new PreReleaseInfoImporter().ImportAsync(context, opts.SeedDir);
await new CardListsImporter().ImportAsync(context, opts.SeedDir);
await new RotationFlagUpdater().UpdateAsync(context);
await new PracticeOpponentImporter().ImportAsync(context, opts.SeedDir);
await new PaymentItemImporter().ImportAsync(context, opts.SeedDir);
var puzzleImporter = new PuzzleImporter();

View File

@@ -1,7 +1,8 @@
namespace SVSim.Database.Models.Config;
/// <summary>
/// Time-varying season/rotation state, populated by GlobalsImporter from prod captures.
/// Time-varying season/rotation state, populated by RotationConfigImporter from seed files.
/// <see cref="RotationCardSetIds"/> drives <c>CardSet.IsInRotation</c> via RotationFlagUpdater.
/// </summary>
[ConfigSection("Rotation")]
public class RotationConfig
@@ -10,6 +11,7 @@ public class RotationConfig
public bool IsBattlePassPeriod { get; set; }
public bool IsBeginnerMission { get; set; }
public int CardSetIdForResourceDlView { get; set; }
public List<int> RotationCardSetIds { get; set; } = new();
public static RotationConfig ShippedDefaults() => new();
}

View File

@@ -0,0 +1,180 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers;
/// <summary>
/// Coverage for CardListsImporter (Stage 9C): one happy-path test per card-list sub-table plus
/// idempotency and orphan-warning behavior. Production seeds reference cards that don't exist in
/// the minimal 3-card test set, so the importer must complete without failing on FK orphans.
/// </summary>
public class CardListsImporterTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task ImportAsync_writes_spot_cards_from_seed()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new CardListsImporter().ImportAsync(db, SeedDir);
var rows = await db.SpotCards.ToListAsync();
Assert.That(rows.Count, Is.GreaterThan(0), "spot-cards.json must produce rows");
Assert.That(rows.All(r => r.Cost >= 0), Is.True, "Cost must be >= 0");
}
[Test]
public async Task ImportAsync_writes_reprinted_cards_from_seed()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new CardListsImporter().ImportAsync(db, SeedDir);
Assert.That(await db.ReprintedCards.CountAsync(), Is.GreaterThan(0),
"reprinted-cards.json must produce rows");
}
[Test]
public async Task ImportAsync_writes_unlimited_restrictions_with_values()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new CardListsImporter().ImportAsync(db, SeedDir);
var rows = await db.UnlimitedRestrictions.ToListAsync();
Assert.That(rows.Count, Is.GreaterThan(0), "unlimited-restrictions.json must produce rows");
// RestrictionValue field must survive the import (e.g. 0 or 1).
Assert.That(rows.All(r => r.RestrictionValue >= 0), Is.True);
}
[Test]
public async Task ImportAsync_writes_loading_exclusion_cards_from_seed()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new CardListsImporter().ImportAsync(db, SeedDir);
Assert.That(await db.LoadingExclusionCards.CountAsync(), Is.GreaterThan(0),
"loading-exclusion-cards.json must produce rows");
}
[Test]
public async Task ImportAsync_handles_empty_maintenance_card_seed()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// The shipped maintenance-cards.json is `[]` — confirm no rows created and no crash.
await new CardListsImporter().ImportAsync(db, SeedDir);
Assert.That(await db.MaintenanceCards.CountAsync(), Is.EqualTo(0),
"Empty maintenance seed should leave the table empty");
}
[Test]
public async Task ImportAsync_handles_empty_feature_maintenance_seed()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new CardListsImporter().ImportAsync(db, SeedDir);
Assert.That(await db.FeatureMaintenances.CountAsync(), Is.EqualTo(0),
"Empty feature-maintenances seed should leave the table empty");
}
[Test]
public async Task ImportAsync_is_idempotent()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new CardListsImporter().ImportAsync(db, SeedDir);
int spots1 = await db.SpotCards.CountAsync();
int reprinted1 = await db.ReprintedCards.CountAsync();
int unlimited1 = await db.UnlimitedRestrictions.CountAsync();
int excl1 = await db.LoadingExclusionCards.CountAsync();
await new CardListsImporter().ImportAsync(db, SeedDir);
int spots2 = await db.SpotCards.CountAsync();
int reprinted2 = await db.ReprintedCards.CountAsync();
int unlimited2 = await db.UnlimitedRestrictions.CountAsync();
int excl2 = await db.LoadingExclusionCards.CountAsync();
Assert.Multiple(() =>
{
Assert.That(spots2, Is.EqualTo(spots1));
Assert.That(reprinted2, Is.EqualTo(reprinted1));
Assert.That(unlimited2, Is.EqualTo(unlimited1));
Assert.That(excl2, Is.EqualTo(excl1));
});
}
[Test]
public async Task ImportAsync_completes_when_seed_card_ids_are_orphans()
{
// The shipped seeds reference card_ids that DON'T exist in SVSimTestFactory's minimal
// 3-card set — the orphan-warning path should log to stderr without throwing.
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
Assert.That(await db.Cards.CountAsync(), Is.EqualTo(3),
"Test factory should seed exactly 3 cards (orphan-warning precondition)");
Assert.DoesNotThrowAsync(async () =>
{
await new CardListsImporter().ImportAsync(db, SeedDir);
});
// Importer still wrote rows despite orphans.
Assert.That(await db.SpotCards.CountAsync(), Is.GreaterThan(0));
}
[Test]
public async Task ImportAsync_writes_feature_maintenances_from_tiny_fixture()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Build a temp seed dir with just feature-maintenances.json populated so we can exercise
// the FeatureMaintenances clear-and-rewrite path without polluting the shipped seeds.
string tmp = Path.Combine(Path.GetTempPath(), $"seed-{Guid.NewGuid()}");
Directory.CreateDirectory(tmp);
try
{
File.WriteAllText(Path.Combine(tmp, "feature-maintenances.json"),
"[{\"id\":1,\"feature_key\":\"test_feature\",\"data\":{\"foo\":\"bar\"}}]");
await new CardListsImporter().ImportAsync(db, tmp);
var rows = await db.FeatureMaintenances.ToListAsync();
Assert.That(rows.Count, Is.EqualTo(1));
Assert.That(rows[0].FeatureKey, Is.EqualTo("test_feature"));
Assert.That(rows[0].Data, Does.Contain("foo"));
// Rerun: clear-and-rewrite should keep the table at 1 row (same data).
await new CardListsImporter().ImportAsync(db, tmp);
Assert.That(await db.FeatureMaintenances.CountAsync(), Is.EqualTo(1));
}
finally { Directory.Delete(tmp, true); }
}
}

View File

@@ -189,9 +189,21 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
using var scope = Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new GlobalsImporter().ImportAllAsync(ctx, capturesDir);
// Per-importer seed pipeline runs alongside GlobalsImporter during the migration.
// Wired here so SeedGlobalsAsync callers (e.g. PracticeControllerTests) still see
// practice-opponent rows after the corresponding block was lifted out of GlobalsImporter.
// Load-index seed pipeline (Stage 9C). Mirrors the wiring in SVSim.Bootstrap.Program.cs:
// RotationConfigImporter must precede RotationFlagUpdater; CardListsImporter is
// ordered after the GameConfig importers for tidiness (no FK dependency).
await new RotationConfigImporter().ImportAsync(ctx, seedDir);
await new MyRotationImporter().ImportAsync(ctx, seedDir);
await new AvatarAbilityImporter().ImportAsync(ctx, seedDir);
await new ArenaSeasonImporter().ImportAsync(ctx, seedDir);
await new BattlePassImporter().ImportAsync(ctx, seedDir);
await new DailyLoginBonusImporter().ImportAsync(ctx, seedDir);
await new PreReleaseInfoImporter().ImportAsync(ctx, seedDir);
await new CardListsImporter().ImportAsync(ctx, seedDir);
await new RotationFlagUpdater().UpdateAsync(ctx);
// Per-importer seed pipeline for the rest of the load-index split.
await new PracticeOpponentImporter().ImportAsync(ctx, seedDir);
await new PaymentItemImporter().ImportAsync(ctx, seedDir);
var puzzleImporter = new PuzzleImporter();