Files
SVSimServer/SVSim.Bootstrap/Importers/BuildDeckImporter.cs
2026-05-26 09:16:21 -04:00

367 lines
16 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 17: products 17, 201299, 301399, 401499, 501599, 601699, 701799 → series 101107
/// Temporary Deck: products 1000110099 → 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(',');
}
}
}