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:
166
SVSim.Bootstrap/Importers/CardListsImporter.cs
Normal file
166
SVSim.Bootstrap/Importers/CardListsImporter.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user