367 lines
16 KiB
C#
367 lines
16 KiB
C#
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>
|
||
/// Loads the prebuilt-deck catalog from a mix of client-master CSVs and one prod-capture 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)
|
||
/// 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.
|
||
/// </summary>
|
||
public class BuildDeckImporter
|
||
{
|
||
private const string BuildDeckSubdir = "build-deck";
|
||
|
||
public async Task<int> ImportSeriesAsync(SVSimDbContext db, string dataDir)
|
||
{
|
||
string csvPath = Path.Combine(dataDir, BuildDeckSubdir, "build_deck_series_master.csv");
|
||
if (!File.Exists(csvPath))
|
||
{
|
||
Console.Error.WriteLine($"[BuildDeckImporter] series CSV missing: {csvPath}");
|
||
return 0;
|
||
}
|
||
|
||
var rows = ReadCsv(csvPath).Skip(1).ToList(); // skip header
|
||
int created = 0, updated = 0;
|
||
|
||
var existing = await db.BuildDeckSeries.ToDictionaryAsync(s => s.Id);
|
||
foreach (var cols in rows)
|
||
{
|
||
if (cols.Length < 5) continue;
|
||
if (!int.TryParse(cols[0], out int id)) continue;
|
||
|
||
if (existing.TryGetValue(id, out var row))
|
||
{
|
||
// Update CSV-derived fields; do not flip IsEnabled or OrderIndex (catalog importer owns those)
|
||
bool changed = false;
|
||
if (row.NameKey != cols[1]) { row.NameKey = cols[1]; changed = true; }
|
||
if (row.IntroKey != cols[2]) { row.IntroKey = cols[2]; changed = true; }
|
||
if (row.TitlePath != cols[3]) { row.TitlePath = cols[3]; changed = true; }
|
||
if (row.DrumrollPath != cols[4]) { row.DrumrollPath = cols[4]; changed = true; }
|
||
if (changed) updated++;
|
||
}
|
||
else
|
||
{
|
||
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
|
||
{
|
||
Id = id,
|
||
NameKey = cols[1],
|
||
IntroKey = cols[2],
|
||
TitlePath = cols[3],
|
||
DrumrollPath = cols[4],
|
||
OrderIndex = 0,
|
||
IsNew = false,
|
||
IsEnabled = false,
|
||
});
|
||
created++;
|
||
}
|
||
}
|
||
await db.SaveChangesAsync();
|
||
Console.WriteLine($"[BuildDeckImporter] Series: created={created}, updated={updated}");
|
||
return created + updated;
|
||
}
|
||
|
||
public async Task<int> ImportPackageAsync(SVSimDbContext db, string dataDir)
|
||
{
|
||
string csvPath = Path.Combine(dataDir, BuildDeckSubdir, "build_deck_package_master.csv");
|
||
if (!File.Exists(csvPath))
|
||
{
|
||
Console.Error.WriteLine($"[BuildDeckImporter] package CSV missing: {csvPath}");
|
||
return 0;
|
||
}
|
||
|
||
var rows = ReadCsv(csvPath).Skip(1).ToList(); // header: product_id,card_id,number,is_spot
|
||
var byProduct = rows
|
||
.Where(c => c.Length >= 4)
|
||
.GroupBy(c => int.Parse(c[0]))
|
||
.ToDictionary(g => g.Key, g => g.Select(c => new BuildDeckProductCardEntry
|
||
{
|
||
CardId = long.Parse(c[1]),
|
||
Number = int.Parse(c[2]),
|
||
IsSpot = int.Parse(c[3]) != 0,
|
||
}).ToList());
|
||
|
||
// Load existing products (we may have stubs from a prior run or rows created by catalog importer)
|
||
var existing = await db.BuildDeckProducts.Include(p => p.Cards).ToDictionaryAsync(p => p.Id);
|
||
int created = 0, updated = 0;
|
||
|
||
foreach (var (productId, cardEntries) in byProduct)
|
||
{
|
||
if (existing.TryGetValue(productId, out var product))
|
||
{
|
||
// Replace card list wholesale — CSV is authoritative.
|
||
product.Cards.Clear();
|
||
foreach (var c in cardEntries) product.Cards.Add(c);
|
||
updated++;
|
||
}
|
||
else
|
||
{
|
||
int? seriesId = InferSeriesId(productId);
|
||
if (seriesId is null)
|
||
{
|
||
Console.Error.WriteLine($"[BuildDeckImporter] product {productId} has no inferable series; skipping");
|
||
continue;
|
||
}
|
||
db.BuildDeckProducts.Add(new BuildDeckProductEntry
|
||
{
|
||
Id = productId,
|
||
SeriesId = seriesId.Value,
|
||
LeaderId = 0,
|
||
DeckCode = string.Empty,
|
||
ProductNameKey = string.Empty,
|
||
FeaturedCardId = 0,
|
||
PurchaseNumMax = 1,
|
||
IntroPriceCrystal = null,
|
||
RegularPriceCrystal = null,
|
||
IntroPriceRupy = null,
|
||
RegularPriceRupy = null,
|
||
IsEnabled = false,
|
||
Cards = cardEntries,
|
||
});
|
||
created++;
|
||
}
|
||
}
|
||
await db.SaveChangesAsync();
|
||
Console.WriteLine($"[BuildDeckImporter] Package: created={created}, updated={updated}");
|
||
return created + updated;
|
||
}
|
||
|
||
public async Task<int> ImportCatalogAsync(SVSimDbContext db, string capturesDir)
|
||
{
|
||
var data = LoadCapture(capturesDir, "build_deck-info");
|
||
if (data is null) 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);
|
||
var existingProducts = await db.BuildDeckProducts
|
||
.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())
|
||
{
|
||
var s = seriesNode.Value;
|
||
int seriesId = GetInt(s, "series_id");
|
||
int orderId = GetInt(s, "order_id");
|
||
bool isNew = GetBool(s, "is_new");
|
||
|
||
if (!existingSeries.TryGetValue(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.
|
||
seriesRow = new BuildDeckSeriesEntry
|
||
{
|
||
Id = seriesId, NameKey = string.Empty, IntroKey = string.Empty,
|
||
TitlePath = string.Empty, DrumrollPath = string.Empty,
|
||
};
|
||
db.BuildDeckSeries.Add(seriesRow);
|
||
existingSeries[seriesId] = seriesRow;
|
||
}
|
||
seriesRow.OrderIndex = orderId;
|
||
seriesRow.IsNew = 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 tier in seriesRewards.EnumerateObject())
|
||
{
|
||
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"),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
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())
|
||
{
|
||
int productId = GetInt(p, "product_id");
|
||
|
||
if (!existingProducts.TryGetValue(productId, out var productRow))
|
||
{
|
||
productRow = new BuildDeckProductEntry { Id = productId, SeriesId = seriesId };
|
||
db.BuildDeckProducts.Add(productRow);
|
||
existingProducts[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.IsEnabled = true;
|
||
|
||
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 rewards.EnumerateObject())
|
||
{
|
||
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"),
|
||
});
|
||
}
|
||
}
|
||
|
||
capturedThisSeries.Add(productRow);
|
||
touchedProducts++;
|
||
}
|
||
|
||
// Second pass: backfill missing tier per-series when sibling products share a unique value.
|
||
BackfillSeriesTier(capturedThisSeries);
|
||
}
|
||
|
||
await db.SaveChangesAsync();
|
||
Console.WriteLine($"[BuildDeckImporter] Catalog: series={touchedSeries}, products={touchedProducts}");
|
||
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.
|
||
/// Sets 1–7: products 1–7, 201–299, 301–399, 401–499, 501–599, 601–699, 701–799 → series 101–107
|
||
/// Temporary Deck: products 10001–10099 → series 10100
|
||
/// Trial series: products NNxx where NN in [119,…,132] → series NN00 (divide-by-100 * 100)
|
||
/// </summary>
|
||
internal static int? InferSeriesId(int productId) => productId switch
|
||
{
|
||
>= 1 and <= 7 => 101,
|
||
>= 201 and <= 299 => 102,
|
||
>= 301 and <= 399 => 103,
|
||
>= 401 and <= 499 => 104,
|
||
>= 501 and <= 599 => 105,
|
||
>= 601 and <= 699 => 106,
|
||
>= 701 and <= 799 => 107,
|
||
>= 10001 and <= 10099 => 10100,
|
||
>= 11901 and <= 13299 => (productId / 100) * 100,
|
||
_ => null,
|
||
};
|
||
|
||
private static IEnumerable<string[]> ReadCsv(string path)
|
||
{
|
||
foreach (var raw in File.ReadAllLines(path, System.Text.Encoding.UTF8))
|
||
{
|
||
// Strip UTF-8 BOM on the first line if present
|
||
var line = raw.TrimStart('');
|
||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||
yield return line.Split(',');
|
||
}
|
||
}
|
||
}
|