refactor(bootstrap): migrate build-deck catalog to seed file

This commit is contained in:
gamer147
2026-05-26 15:16:36 -04:00
parent a71bf6c62b
commit 34ed8788a4
6 changed files with 1744 additions and 159 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,16 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
using static SVSim.Bootstrap.Importers.ImporterBase;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Loads the prebuilt-deck catalog from a mix of client-master CSVs and one prod-capture JSON.
/// Loads the prebuilt-deck catalog from a mix of client-master CSVs and one seed JSON.
/// Three methods run in dependency order (see Bootstrap/Program.cs):
/// 1. ImportSeriesAsync — build_deck_series_master.csv → 22 series rows (all IsEnabled=false initially)
/// 2. ImportCatalogAsync — prod-captures/build_deck-info-*.json → enriches 7 series + 53 products (Task 15)
/// 2. ImportCatalogAsync — seeds/build-deck-catalog.json → enriches 7 series + 53 products
/// (tier backfill for missing intro/regular prices is performed by the extractor)
/// 3. ImportPackageAsync — build_deck_package_master.csv → card lists for all 112 products,
/// creates disabled stubs for products not seeded by the catalog importer
/// Idempotent — re-runnable on the same files.
@@ -133,14 +133,13 @@ public class BuildDeckImporter
return created + updated;
}
public async Task<int> ImportCatalogAsync(SVSimDbContext db, string capturesDir)
public async Task<int> ImportCatalogAsync(SVSimDbContext db, string seedDir)
{
var data = LoadCapture(capturesDir, "build_deck-info");
if (data is null) return 0;
var seed = SeedLoader.LoadList<BuildDeckCatalogSeed>(Path.Combine(seedDir, "build-deck-catalog.json"));
if (seed.Count == 0) return 0;
int touchedSeries = 0, touchedProducts = 0;
// Load existing rows for fast lookup
var existingSeries = await db.BuildDeckSeries
.Include(s => s.SeriesRewards)
.ToDictionaryAsync(s => s.Id);
@@ -148,126 +147,75 @@ public class BuildDeckImporter
.Include(p => p.Rewards)
.ToDictionaryAsync(p => p.Id);
// The captured data root is an object keyed by order_id string ("15"…"21"); iterate values.
foreach (var seriesNode in data.Value.EnumerateObject())
foreach (var s in seed)
{
var s = seriesNode.Value;
int seriesId = GetInt(s, "series_id");
int orderId = GetInt(s, "order_id");
bool isNew = GetBool(s, "is_new");
if (s.SeriesId == 0) continue;
if (!existingSeries.TryGetValue(seriesId, out var seriesRow))
if (!existingSeries.TryGetValue(s.SeriesId, out var seriesRow))
{
// Catalog runs before package importer in production, so series rows from the series
// CSV should already exist. If not (e.g. the capture has a series the CSV doesn't),
// create a bare row so the FK from products holds.
// Catalog typically runs after the series CSV; if a seed series isn't in the
// CSV we create a bare stub so the FK from products holds.
seriesRow = new BuildDeckSeriesEntry
{
Id = seriesId, NameKey = string.Empty, IntroKey = string.Empty,
Id = s.SeriesId, NameKey = string.Empty, IntroKey = string.Empty,
TitlePath = string.Empty, DrumrollPath = string.Empty,
};
db.BuildDeckSeries.Add(seriesRow);
existingSeries[seriesId] = seriesRow;
existingSeries[s.SeriesId] = seriesRow;
}
seriesRow.OrderIndex = orderId;
seriesRow.IsNew = isNew;
seriesRow.OrderIndex = s.OrderId;
seriesRow.IsNew = s.IsNew;
seriesRow.IsEnabled = true;
// Series rewards: replace wholesale (capture is authoritative for enabled series)
seriesRow.SeriesRewards.Clear();
if (s.TryGetProperty("series_rewards", out var seriesRewards) &&
seriesRewards.ValueKind == JsonValueKind.Object)
foreach (var r in s.SeriesRewards)
{
foreach (var tier in seriesRewards.EnumerateObject())
seriesRow.SeriesRewards.Add(new BuildDeckSeriesRewardEntry
{
if (!int.TryParse(tier.Name, out int tierIndex)) continue;
if (!tier.Value.TryGetProperty("reward_list", out var rewardList) ||
rewardList.ValueKind != JsonValueKind.Array) continue;
int itemIndex = 0;
foreach (var r in rewardList.EnumerateArray())
{
seriesRow.SeriesRewards.Add(new BuildDeckSeriesRewardEntry
{
TierIndex = tierIndex,
ItemIndex = itemIndex++,
RewardType = GetInt(r, "reward_type"),
RewardDetailId = GetLong(r, "reward_detail_id"),
RewardNumber = GetInt(r, "reward_number"),
MessageId = GetInt(r, "message_id"),
});
}
}
TierIndex = r.TierIndex,
ItemIndex = r.ItemIndex,
RewardType = r.RewardType,
RewardDetailId = r.RewardDetailId,
RewardNumber = r.RewardNumber,
MessageId = r.MessageId,
});
}
touchedSeries++;
// Products
if (!s.TryGetProperty("products", out var products) || products.ValueKind != JsonValueKind.Array)
continue;
// First pass: parse each captured product, track intro/regular tiers per product.
var capturedThisSeries = new List<BuildDeckProductEntry>();
foreach (var p in products.EnumerateArray())
foreach (var p in s.Products)
{
int productId = GetInt(p, "product_id");
if (!existingProducts.TryGetValue(productId, out var productRow))
if (!existingProducts.TryGetValue(p.ProductId, out var productRow))
{
productRow = new BuildDeckProductEntry { Id = productId, SeriesId = seriesId };
productRow = new BuildDeckProductEntry { Id = p.ProductId, SeriesId = s.SeriesId };
db.BuildDeckProducts.Add(productRow);
existingProducts[productId] = productRow;
existingProducts[p.ProductId] = productRow;
}
productRow.SeriesId = seriesId;
productRow.LeaderId = GetInt(p, "leader_id");
productRow.DeckCode = GetString(p, "deck_code");
productRow.ProductNameKey = GetString(p, "product_name");
productRow.FeaturedCardId = GetLong(p, "featured_card_id");
productRow.PurchaseNumMax = GetInt(p, "purchase_num_max");
productRow.SeriesId = s.SeriesId;
productRow.LeaderId = p.LeaderId;
productRow.DeckCode = p.DeckCode;
productRow.ProductNameKey = p.ProductName;
productRow.FeaturedCardId = p.FeaturedCardId;
productRow.PurchaseNumMax = p.PurchaseNumMax;
productRow.IsEnabled = true;
productRow.IntroPriceCrystal = p.IntroPriceCrystal;
productRow.RegularPriceCrystal = p.RegularPriceCrystal;
productRow.IntroPriceRupy = p.IntroPriceRupy;
productRow.RegularPriceRupy = p.RegularPriceRupy;
bool isFirstPrice = GetBool(p, "is_first_price");
// Tier-aware price ingestion: each captured row has ONE price tier (intro OR regular).
int? priceCrystal = p.TryGetProperty("price_crystal", out var pc) && pc.ValueKind != JsonValueKind.Null
? (int?)GetInt(p, "price_crystal") : null;
int? priceRupy = p.TryGetProperty("price_rupy", out var pr) && pr.ValueKind != JsonValueKind.Null
? (int?)GetInt(p, "price_rupy") : null;
if (priceCrystal is not null)
{
if (isFirstPrice) productRow.IntroPriceCrystal = priceCrystal;
else productRow.RegularPriceCrystal = priceCrystal;
}
if (priceRupy is not null)
{
if (isFirstPrice) productRow.IntroPriceRupy = priceRupy;
else productRow.RegularPriceRupy = priceRupy;
}
// Product rewards: replace wholesale
productRow.Rewards.Clear();
if (p.TryGetProperty("rewards", out var rewards) && rewards.ValueKind == JsonValueKind.Object)
foreach (var r in p.Rewards)
{
foreach (var r in rewards.EnumerateObject())
productRow.Rewards.Add(new BuildDeckProductRewardEntry
{
if (!int.TryParse(r.Name, out int idx)) continue;
productRow.Rewards.Add(new BuildDeckProductRewardEntry
{
RewardIndex = idx,
RewardType = GetInt(r.Value, "reward_type"),
RewardDetailId = GetLong(r.Value, "reward_detail_id"),
RewardNumber = GetInt(r.Value, "reward_number"),
MessageId = GetInt(r.Value, "message_id"),
});
}
RewardIndex = r.RewardIndex,
RewardType = r.RewardType,
RewardDetailId = r.RewardDetailId,
RewardNumber = r.RewardNumber,
MessageId = r.MessageId,
});
}
capturedThisSeries.Add(productRow);
touchedProducts++;
}
// Second pass: backfill missing tier per-series when sibling products share a unique value.
BackfillSeriesTier(capturedThisSeries);
}
await db.SaveChangesAsync();
@@ -275,63 +223,6 @@ public class BuildDeckImporter
return touchedSeries + touchedProducts;
}
private static void BackfillSeriesTier(IReadOnlyList<BuildDeckProductEntry> productsInSeries)
{
// For each (Currency, Tier) pair, if all populated values across siblings are the same,
// propagate that value to products that are missing the corresponding tier.
BackfillIntroCrystal(productsInSeries);
BackfillRegularCrystal(productsInSeries);
BackfillIntroRupy(productsInSeries);
BackfillRegularRupy(productsInSeries);
}
private static void BackfillIntroCrystal(IReadOnlyList<BuildDeckProductEntry> products)
{
var distinct = products.Where(p => p.IntroPriceCrystal.HasValue).Select(p => p.IntroPriceCrystal!.Value).Distinct().ToList();
if (distinct.Count != 1) return;
int value = distinct[0];
foreach (var p in products)
{
if (p.IntroPriceCrystal is null) p.IntroPriceCrystal = value;
}
}
private static void BackfillRegularCrystal(IReadOnlyList<BuildDeckProductEntry> products)
{
var distinct = products.Where(p => p.RegularPriceCrystal.HasValue).Select(p => p.RegularPriceCrystal!.Value).Distinct().ToList();
if (distinct.Count != 1) return;
int value = distinct[0];
foreach (var p in products)
{
// For PurchaseNumMax == 1 products, never backfill the Regular tier — they have no second buy.
if (p.PurchaseNumMax <= 1) continue;
if (p.RegularPriceCrystal is null) p.RegularPriceCrystal = value;
}
}
private static void BackfillIntroRupy(IReadOnlyList<BuildDeckProductEntry> products)
{
var distinct = products.Where(p => p.IntroPriceRupy.HasValue).Select(p => p.IntroPriceRupy!.Value).Distinct().ToList();
if (distinct.Count != 1) return;
int value = distinct[0];
foreach (var p in products)
{
if (p.IntroPriceRupy is null) p.IntroPriceRupy = value;
}
}
private static void BackfillRegularRupy(IReadOnlyList<BuildDeckProductEntry> products)
{
var distinct = products.Where(p => p.RegularPriceRupy.HasValue).Select(p => p.RegularPriceRupy!.Value).Distinct().ToList();
if (distinct.Count != 1) return;
int value = distinct[0];
foreach (var p in products)
{
if (p.PurchaseNumMax <= 1) continue;
if (p.RegularPriceRupy is null) p.RegularPriceRupy = value;
}
}
/// <summary>
/// Maps a product_id to its series_id using the numeric pattern derived from the /info capture
/// and CSV inspection.

View File

@@ -0,0 +1,46 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class BuildDeckCatalogSeed
{
[JsonPropertyName("series_id")] public int SeriesId { get; set; }
[JsonPropertyName("order_id")] public int OrderId { get; set; }
[JsonPropertyName("is_new")] public bool IsNew { get; set; }
[JsonPropertyName("series_rewards")] public List<BuildDeckSeriesRewardSeed> SeriesRewards { get; set; } = new();
[JsonPropertyName("products")] public List<BuildDeckProductSeed> Products { get; set; } = new();
}
public sealed class BuildDeckSeriesRewardSeed
{
[JsonPropertyName("tier_index")] public int TierIndex { get; set; }
[JsonPropertyName("item_index")] public int ItemIndex { get; set; }
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
[JsonPropertyName("message_id")] public int MessageId { get; set; }
}
public sealed class BuildDeckProductSeed
{
[JsonPropertyName("product_id")] public int ProductId { get; set; }
[JsonPropertyName("leader_id")] public int LeaderId { get; set; }
[JsonPropertyName("deck_code")] public string DeckCode { get; set; } = "";
[JsonPropertyName("product_name")] public string ProductName { get; set; } = "";
[JsonPropertyName("featured_card_id")] public long FeaturedCardId { get; set; }
[JsonPropertyName("purchase_num_max")] public int PurchaseNumMax { get; set; }
[JsonPropertyName("intro_price_crystal")] public int? IntroPriceCrystal { get; set; }
[JsonPropertyName("regular_price_crystal")] public int? RegularPriceCrystal { get; set; }
[JsonPropertyName("intro_price_rupy")] public int? IntroPriceRupy { get; set; }
[JsonPropertyName("regular_price_rupy")] public int? RegularPriceRupy { get; set; }
[JsonPropertyName("rewards")] public List<BuildDeckProductRewardSeed> Rewards { get; set; } = new();
}
public sealed class BuildDeckProductRewardSeed
{
[JsonPropertyName("reward_index")] public int RewardIndex { get; set; }
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
[JsonPropertyName("message_id")] public int MessageId { get; set; }
}

View File

@@ -98,7 +98,7 @@ public static class Program
// enriched rows take precedence over stub creation).
var buildDeck = new BuildDeckImporter();
await buildDeck.ImportSeriesAsync(context, opts.ReferenceDataDir);
await buildDeck.ImportCatalogAsync(context, opts.CapturesDir);
await buildDeck.ImportCatalogAsync(context, opts.SeedDir);
await buildDeck.ImportPackageAsync(context, opts.ReferenceDataDir);
}
else

View File

@@ -9,6 +9,7 @@ namespace SVSim.UnitTests.Importers;
public class BuildDeckImporterTests
{
private static string DataDir => Path.Combine(AppContext.BaseDirectory, "Data");
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task ImportsAll22Series_with_22_disabled_until_catalog_enables()
@@ -71,7 +72,7 @@ public class BuildDeckImporterTests
var importer = new BuildDeckImporter();
await importer.ImportSeriesAsync(db, DataDir);
await importer.ImportCatalogAsync(db, Path.Combine(DataDir, "prod-captures"));
await importer.ImportCatalogAsync(db, SeedDir);
await importer.ImportPackageAsync(db, DataDir);
// Series 101 (Set 1) should be enabled and order_id=22 from capture