Prebuilt deck purchasing and fixes

This commit is contained in:
gamer147
2026-05-26 09:16:21 -04:00
parent fa0901b776
commit b6966ece6e
39 changed files with 7392 additions and 15 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
series_id,series_name,introduction,title_path,drumroll_path
13200,BDSSN_トライアル_32,BDSI_トライアル_32,build_deck_13200_logo_02,build_deck_13200_logo_01
13100,BDSSN_トライアル_31,BDSI_トライアル_31,build_deck_13100_logo_02,build_deck_13100_logo_01
13000,BDSSN_トライアル_30,BDSI_トライアル_30,build_deck_13000_logo_02,build_deck_13000_logo_01
12900,BDSSN_トライアル_29,BDSI_トライアル_29,build_deck_12900_logo_02,build_deck_12900_logo_01
12800,BDSSN_トライアル_28,BDSI_トライアル_28,build_deck_12800_logo_02,build_deck_12800_logo_01
12700,BDSSN_トライアル_27,BDSI_トライアル_27,build_deck_12700_logo_02,build_deck_12700_logo_01
12600,BDSSN_トライアル_26,BDSI_トライアル_26,build_deck_12600_logo_02,build_deck_12600_logo_01
12500,BDSSN_トライアル_25,BDSI_トライアル_25,build_deck_12500_logo_02,build_deck_12500_logo_01
12400,BDSSN_トライアル_24,BDSI_トライアル_24,build_deck_12400_logo_02,build_deck_12400_logo_01
12300,BDSSN_トライアル_23,BDSI_トライアル_23,build_deck_12300_logo_02,build_deck_12300_logo_01
12200,BDSSN_トライアル_22,BDSI_トライアル_22,build_deck_12200_logo_02,build_deck_12200_logo_01
12100,BDSSN_トライアル_21,BDSI_トライアル_21,build_deck_12100_logo_02,build_deck_12100_logo_01
12000,BDSSN_トライアル_20,BDSI_トライアル_20,build_deck_12000_logo_02,build_deck_12000_logo_01
11900,BDSSN_トライアル_19,BDSI_トライアル_19,build_deck_11900_logo_02,build_deck_11900_logo_01
10100,BDSSN_テンポラリーデッキ,BDSI_テンポラリーデッキ,build_deck_10100_logo_02,build_deck_10100_logo_01
107,BDSSN_構築済みデッキ7弾,BDSI_構築済みデッキ7弾,build_deck_107_logo_02,build_deck_107_logo_01
106,BDSSN_構築済みデッキ6弾,BDSI_構築済みデッキ6弾,build_deck_106_logo_02,build_deck_106_logo_01
105,BDSSN_構築済みデッキ5弾,BDSI_構築済みデッキ5弾,build_deck_105_logo_02,build_deck_105_logo_01
104,BDSSN_構築済みデッキ4弾,BDSI_構築済みデッキ4弾,build_deck_104_logo_02,build_deck_104_logo_01
103,BDSSN_構築済みデッキ3弾,BDSI_構築済みデッキ3弾,build_deck_103_logo_02,build_deck_103_logo_01
102,BDSSN_構築済みデッキ2弾,BDSI_構築済みデッキ2弾,build_deck_102_logo_02,build_deck_102_logo_01
101,BDSSN_構築済みデッキ1弾,BDSI_構築済みデッキ1弾,build_deck_101_logo_02,build_deck_101_logo_01
1 series_id series_name introduction title_path drumroll_path
2 13200 BDSSN_トライアル_32 BDSI_トライアル_32 build_deck_13200_logo_02 build_deck_13200_logo_01
3 13100 BDSSN_トライアル_31 BDSI_トライアル_31 build_deck_13100_logo_02 build_deck_13100_logo_01
4 13000 BDSSN_トライアル_30 BDSI_トライアル_30 build_deck_13000_logo_02 build_deck_13000_logo_01
5 12900 BDSSN_トライアル_29 BDSI_トライアル_29 build_deck_12900_logo_02 build_deck_12900_logo_01
6 12800 BDSSN_トライアル_28 BDSI_トライアル_28 build_deck_12800_logo_02 build_deck_12800_logo_01
7 12700 BDSSN_トライアル_27 BDSI_トライアル_27 build_deck_12700_logo_02 build_deck_12700_logo_01
8 12600 BDSSN_トライアル_26 BDSI_トライアル_26 build_deck_12600_logo_02 build_deck_12600_logo_01
9 12500 BDSSN_トライアル_25 BDSI_トライアル_25 build_deck_12500_logo_02 build_deck_12500_logo_01
10 12400 BDSSN_トライアル_24 BDSI_トライアル_24 build_deck_12400_logo_02 build_deck_12400_logo_01
11 12300 BDSSN_トライアル_23 BDSI_トライアル_23 build_deck_12300_logo_02 build_deck_12300_logo_01
12 12200 BDSSN_トライアル_22 BDSI_トライアル_22 build_deck_12200_logo_02 build_deck_12200_logo_01
13 12100 BDSSN_トライアル_21 BDSI_トライアル_21 build_deck_12100_logo_02 build_deck_12100_logo_01
14 12000 BDSSN_トライアル_20 BDSI_トライアル_20 build_deck_12000_logo_02 build_deck_12000_logo_01
15 11900 BDSSN_トライアル_19 BDSI_トライアル_19 build_deck_11900_logo_02 build_deck_11900_logo_01
16 10100 BDSSN_テンポラリーデッキ BDSI_テンポラリーデッキ build_deck_10100_logo_02 build_deck_10100_logo_01
17 107 BDSSN_構築済みデッキ7弾 BDSI_構築済みデッキ7弾 build_deck_107_logo_02 build_deck_107_logo_01
18 106 BDSSN_構築済みデッキ6弾 BDSI_構築済みデッキ6弾 build_deck_106_logo_02 build_deck_106_logo_01
19 105 BDSSN_構築済みデッキ5弾 BDSI_構築済みデッキ5弾 build_deck_105_logo_02 build_deck_105_logo_01
20 104 BDSSN_構築済みデッキ4弾 BDSI_構築済みデッキ4弾 build_deck_104_logo_02 build_deck_104_logo_01
21 103 BDSSN_構築済みデッキ3弾 BDSI_構築済みデッキ3弾 build_deck_103_logo_02 build_deck_103_logo_01
22 102 BDSSN_構築済みデッキ2弾 BDSI_構築済みデッキ2弾 build_deck_102_logo_02 build_deck_102_logo_01
23 101 BDSSN_構築済みデッキ1弾 BDSI_構築済みデッキ1弾 build_deck_101_logo_02 build_deck_101_logo_01

File diff suppressed because one or more lines are too long

View File

@@ -457,7 +457,7 @@
"4": {
"class_id": 4,
"is_random_leader_skin": 0,
"leader_skin_id": 104
"leader_skin_id": 4
},
"5": {
"class_id": 5,
@@ -467,7 +467,7 @@
"6": {
"class_id": 6,
"is_random_leader_skin": 0,
"leader_skin_id": 106
"leader_skin_id": 6
},
"7": {
"class_id": 7,

View File

@@ -0,0 +1,366 @@
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(',');
}
}
}

View File

@@ -76,6 +76,14 @@ public static class Program
if (!opts.SkipGlobals)
{
await new GlobalsImporter().ImportAllAsync(context, opts.CapturesDir);
// 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.CapturesDir);
await buildDeck.ImportPackageAsync(context, opts.ReferenceDataDir);
}
else
{

View File

@@ -16,6 +16,9 @@
<Content Include="Data\*.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Data\build-deck\*.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- cards.json is the loader-captured card master (~30 MB). Lives in-project so the
bootstrapper is self-contained; refresh by copying a newer dump over the file. -->
<Content Include="Data\cards.json">