Idempotent upsert importer for arena-two-pick-rewards.json; 2 NUnit tests (16-row load + idempotency). Wired into Program.cs globals pipeline after ArenaSeasonImporter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
251 lines
12 KiB
C#
251 lines
12 KiB
C#
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.SkipReference && opts.SkipCards && opts.SkipGlobals && opts.SkipStory)
|
|
{
|
|
Console.Error.WriteLine("All --skip-* flags set; nothing to do.");
|
|
return 1;
|
|
}
|
|
|
|
Console.WriteLine($"[Bootstrap] Connection: {RedactPassword(opts.ConnectionString)}");
|
|
Console.WriteLine($"[Bootstrap] Reference CSVs: {opts.ReferenceDataDir}");
|
|
Console.WriteLine($"[Bootstrap] Cards file: {opts.CardsFile}");
|
|
Console.WriteLine($"[Bootstrap] Seeds: {opts.SeedDir}");
|
|
|
|
var dbOptions = new DbContextOptionsBuilder<SVSimDbContext>()
|
|
.UseNpgsql(opts.ConnectionString)
|
|
.Options;
|
|
|
|
await using var context = new SVSimDbContext(NullLogger<SVSimDbContext>.Instance, dbOptions);
|
|
|
|
// Bootstrap applies pending migrations first — migrations are now DDL-only, all data
|
|
// (reference tables, cards, card cosmetic rewards, per-table seed globals, game config)
|
|
// is loaded by importers below. This means a freshly migrated DB is structure-only;
|
|
// every importer is idempotent so re-running is safe.
|
|
Console.WriteLine("[Bootstrap] Applying pending migrations...");
|
|
await context.Database.MigrateAsync();
|
|
|
|
// GameConfigSection rows for every [ConfigSection] type — runtime seed (HasData doesn't
|
|
// play well with OwnsOne+ToJson). Always run; tiers only insert missing sections.
|
|
await context.EnsureSeedDataAsync();
|
|
|
|
if (!opts.SkipReference)
|
|
{
|
|
await new ReferenceDataImporter().ImportAllAsync(context, opts.ReferenceDataDir);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("[Bootstrap] --skip-reference set; skipping reference data import.");
|
|
}
|
|
|
|
if (!opts.SkipCards)
|
|
{
|
|
await new CardImporter().ImportAsync(context, opts.CardsFile);
|
|
// Card cosmetic rewards FK to Cards; piggy-back on --skip-cards.
|
|
await new CardCosmeticRewardImporter().ImportAsync(context, opts.ReferenceDataDir);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("[Bootstrap] --skip-cards set; skipping card + cosmetic-reward import.");
|
|
}
|
|
|
|
if (!opts.SkipGlobals)
|
|
{
|
|
// Per-domain seed pipeline. Each importer reads a per-table JSON seed file under
|
|
// SVSim.Bootstrap/Data/seeds/ produced by an extractor in data_dumps/scripts/.
|
|
//
|
|
// 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 ArenaTwoPickRewardImporter().ImportAsync(context, opts.SeedDir);
|
|
await new BattlePassImporter().ImportAsync(context, opts.SeedDir);
|
|
await new BattlePassSeasonImporter().ImportAsync(context, opts.SeedDir);
|
|
await new BattlePassRewardImporter().ImportAsync(context, opts.SeedDir);
|
|
await new MissionCatalogImporter().ImportAsync(context, opts.SeedDir);
|
|
await new AchievementCatalogImporter().ImportAsync(context, opts.SeedDir);
|
|
await new BattlePassMonthlyMissionImporter().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);
|
|
await new ItemImporter().ImportAsync(context, opts.SeedDir);
|
|
await new SleeveShopImporter().ImportAsync(context, opts.SeedDir);
|
|
await new ItemPurchaseImporter().ImportAsync(context, opts.SeedDir);
|
|
await new LeaderSkinShopImporter().ImportAsync(context, opts.SeedDir);
|
|
await new SpotCardExchangeImporter().ImportAsync(context, opts.SeedDir);
|
|
var puzzleImporter = new PuzzleImporter();
|
|
await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir);
|
|
await puzzleImporter.ImportPuzzlesAsync(context, opts.SeedDir);
|
|
await puzzleImporter.ImportMissionsAsync(context, opts.SeedDir);
|
|
|
|
var mypage = new MyPageGlobalsImporter();
|
|
await mypage.ImportBannersAsync(context, opts.SeedDir);
|
|
await mypage.ImportColosseumAsync(context, opts.SeedDir);
|
|
await mypage.ImportSealedAsync(context, opts.SeedDir);
|
|
await mypage.ImportMasterPointRankingPeriodAsync(context, opts.SeedDir);
|
|
await mypage.ImportSpecialDeckFormatsAsync(context, opts.SeedDir);
|
|
|
|
await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir);
|
|
await new PackImporter().ImportAsync(context, opts.SeedDir);
|
|
await new PackDrawTableImporter().ImportAsync(context, opts.SeedDir);
|
|
|
|
// BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after
|
|
// series CSV (FK on products → series) and before package CSV (so the catalog-side
|
|
// enriched rows take precedence over stub creation).
|
|
var buildDeck = new BuildDeckImporter();
|
|
await buildDeck.ImportSeriesAsync(context, opts.ReferenceDataDir);
|
|
await buildDeck.ImportCatalogAsync(context, opts.SeedDir);
|
|
await buildDeck.ImportPackageAsync(context, opts.ReferenceDataDir);
|
|
await new StoryDeckImporter().ImportAsync(context, opts.SeedDir);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("[Bootstrap] --skip-globals set; skipping globals import.");
|
|
}
|
|
|
|
if (!opts.SkipStory)
|
|
{
|
|
await new StoryImporter().ImportAsync(context, opts.StoryDataDir);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("[Bootstrap] --skip-story set; skipping story import.");
|
|
}
|
|
|
|
Console.WriteLine("[Bootstrap] Complete.");
|
|
return 0;
|
|
}
|
|
|
|
private static BootstrapOptions? ParseArgs(string[] args)
|
|
{
|
|
string? cards = null;
|
|
string? referenceDataDir = null;
|
|
string? connection = null;
|
|
bool skipReference = false;
|
|
bool skipCards = false;
|
|
bool skipGlobals = false;
|
|
bool skipStory = false;
|
|
string? storyDataDir = null;
|
|
string? positionalCards = null;
|
|
|
|
for (int i = 0; i < args.Length; i++)
|
|
{
|
|
string a = args[i];
|
|
switch (a)
|
|
{
|
|
case "--cards": cards = NextArg(args, ref i); break;
|
|
case "--reference-data-dir": referenceDataDir = NextArg(args, ref i); break;
|
|
case "--connection-string": connection = NextArg(args, ref i); break;
|
|
case "--skip-reference": skipReference = true; break;
|
|
case "--skip-cards": skipCards = true; break;
|
|
case "--skip-globals": skipGlobals = true; break;
|
|
case "--skip-story": skipStory = true; break;
|
|
case "--story-data-dir": storyDataDir = NextArg(args, ref i); 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;
|
|
}
|
|
}
|
|
|
|
// All bootstrap inputs ship in-project under SVSim.Bootstrap/Data/, copied next to the
|
|
// binary on build. The --cards/--reference-data-dir flags are ad-hoc overrides
|
|
// (e.g. point at a fresh loader dump before promoting it into the project).
|
|
string baseDir = AppContext.BaseDirectory;
|
|
string shippedDataDir = Path.Combine(baseDir, "Data");
|
|
string shippedCardsFile = Path.Combine(shippedDataDir, "cards.json");
|
|
|
|
string cardsFile = cards ?? positionalCards ?? shippedCardsFile;
|
|
string refDir = referenceDataDir ?? shippedDataDir;
|
|
string shippedStoryDir = Path.Combine(shippedDataDir, "story");
|
|
string storyDir = storyDataDir ?? shippedStoryDir;
|
|
string shippedSeedDir = Path.Combine(shippedDataDir, "seeds");
|
|
|
|
string connStr = connection
|
|
?? Environment.GetEnvironmentVariable("NPGSQL_CONNECTION")
|
|
?? DefaultConnectionString;
|
|
|
|
return new BootstrapOptions(
|
|
cardsFile, refDir, connStr, skipReference, skipCards, skipGlobals,
|
|
skipStory, storyDir, shippedSeedDir);
|
|
}
|
|
|
|
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" +
|
|
" All inputs default to the in-project SVSim.Bootstrap/Data/ folder, copied next to\n" +
|
|
" the binary at build time. Override flags below take ad-hoc paths (e.g. a fresh\n" +
|
|
" loader dump) — promote into Data/ when you're ready to make it permanent.\n" +
|
|
"\n" +
|
|
" --cards <file> Override path to cards.json (default: shipped Data/cards.json)\n" +
|
|
" --reference-data-dir <dir> Override reference CSV directory (default: shipped Data/)\n" +
|
|
" --connection-string <conn> Postgres connection (or NPGSQL_CONNECTION env var,\n" +
|
|
$" then \"{DefaultConnectionString}\")\n" +
|
|
" --skip-reference Skip reference-data import (classes, sleeves, ranks, ...)\n" +
|
|
" --skip-cards Skip card + card-cosmetic-reward import\n" +
|
|
" --skip-globals Skip seed-driven globals import (per-table JSON under Data/seeds)\n" +
|
|
" --story-data-dir <dir> Override story data directory (default: shipped Data/story)\n" +
|
|
" --skip-story Skip story import (worlds/sections/chapters/sbs)\n" +
|
|
"\n" +
|
|
"Capture-derived seeds are produced by extractors under data_dumps/scripts/* and\n" +
|
|
"checked into SVSim.Bootstrap/Data/seeds/. The bootstrap project never parses wire\n" +
|
|
"captures directly — refresh seeds by re-running the relevant extractor.\n" +
|
|
"\n" +
|
|
"Back-compat: `svsim-bootstrap <cards.json> [connection]` still works (positional).");
|
|
}
|
|
|
|
private sealed record BootstrapOptions(
|
|
string CardsFile,
|
|
string ReferenceDataDir,
|
|
string ConnectionString,
|
|
bool SkipReference,
|
|
bool SkipCards,
|
|
bool SkipGlobals,
|
|
bool SkipStory,
|
|
string StoryDataDir,
|
|
string SeedDir);
|
|
}
|