Prebuilt deck purchasing and fixes
This commit is contained in:
1885
SVSim.Bootstrap/Data/build-deck/build_deck_package_master.csv
Normal file
1885
SVSim.Bootstrap/Data/build-deck/build_deck_package_master.csv
Normal file
File diff suppressed because it is too large
Load Diff
23
SVSim.Bootstrap/Data/build-deck/build_deck_series_master.csv
Normal file
23
SVSim.Bootstrap/Data/build-deck/build_deck_series_master.csv
Normal 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
|
||||
|
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
366
SVSim.Bootstrap/Importers/BuildDeckImporter.cs
Normal file
366
SVSim.Bootstrap/Importers/BuildDeckImporter.cs
Normal 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 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(',');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
2834
SVSim.Database/Migrations/20260526043148_AddBuildDeck.Designer.cs
generated
Normal file
2834
SVSim.Database/Migrations/20260526043148_AddBuildDeck.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
191
SVSim.Database/Migrations/20260526043148_AddBuildDeck.cs
Normal file
191
SVSim.Database/Migrations/20260526043148_AddBuildDeck.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBuildDeck : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BuildDeckSeries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
OrderIndex = table.Column<int>(type: "integer", nullable: false),
|
||||
NameKey = table.Column<string>(type: "text", nullable: false),
|
||||
IntroKey = table.Column<string>(type: "text", nullable: false),
|
||||
TitlePath = table.Column<string>(type: "text", nullable: false),
|
||||
DrumrollPath = table.Column<string>(type: "text", nullable: false),
|
||||
IsNew = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BuildDeckSeries", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerBuildDeckProductPurchase",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ProductId = table.Column<int>(type: "integer", nullable: false),
|
||||
PurchaseCount = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerBuildDeckProductPurchase", x => new { x.ViewerId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerBuildDeckProductPurchase_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BuildDeckProducts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
SeriesId = table.Column<int>(type: "integer", nullable: false),
|
||||
LeaderId = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckCode = table.Column<string>(type: "text", nullable: false),
|
||||
ProductNameKey = table.Column<string>(type: "text", nullable: false),
|
||||
FeaturedCardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PurchaseNumMax = table.Column<int>(type: "integer", nullable: false),
|
||||
IntroPriceCrystal = table.Column<int>(type: "integer", nullable: true),
|
||||
RegularPriceCrystal = table.Column<int>(type: "integer", nullable: true),
|
||||
IntroPriceRupy = table.Column<int>(type: "integer", nullable: true),
|
||||
RegularPriceRupy = table.Column<int>(type: "integer", nullable: true),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BuildDeckProducts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BuildDeckProducts_BuildDeckSeries_SeriesId",
|
||||
column: x => x.SeriesId,
|
||||
principalTable: "BuildDeckSeries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BuildDeckSeriesRewardEntry",
|
||||
columns: table => new
|
||||
{
|
||||
BuildDeckSeriesEntryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TierIndex = table.Column<int>(type: "integer", nullable: false),
|
||||
ItemIndex = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardType = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardNumber = table.Column<int>(type: "integer", nullable: false),
|
||||
MessageId = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BuildDeckSeriesRewardEntry", x => new { x.BuildDeckSeriesEntryId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_BuildDeckSeriesRewardEntry_BuildDeckSeries_BuildDeckSeriesE~",
|
||||
column: x => x.BuildDeckSeriesEntryId,
|
||||
principalTable: "BuildDeckSeries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BuildDeckProductCardEntry",
|
||||
columns: table => new
|
||||
{
|
||||
BuildDeckProductEntryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
CardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Number = table.Column<int>(type: "integer", nullable: false),
|
||||
IsSpot = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BuildDeckProductCardEntry", x => new { x.BuildDeckProductEntryId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_BuildDeckProductCardEntry_BuildDeckProducts_BuildDeckProduc~",
|
||||
column: x => x.BuildDeckProductEntryId,
|
||||
principalTable: "BuildDeckProducts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BuildDeckProductRewardEntry",
|
||||
columns: table => new
|
||||
{
|
||||
BuildDeckProductEntryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
RewardIndex = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardType = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardNumber = table.Column<int>(type: "integer", nullable: false),
|
||||
MessageId = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BuildDeckProductRewardEntry", x => new { x.BuildDeckProductEntryId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_BuildDeckProductRewardEntry_BuildDeckProducts_BuildDeckProd~",
|
||||
column: x => x.BuildDeckProductEntryId,
|
||||
principalTable: "BuildDeckProducts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BuildDeckProducts_SeriesId",
|
||||
table: "BuildDeckProducts",
|
||||
column: "SeriesId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerBuildDeckProductPurchase_ViewerId_ProductId",
|
||||
table: "ViewerBuildDeckProductPurchase",
|
||||
columns: new[] { "ViewerId", "ProductId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BuildDeckProductCardEntry");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "BuildDeckProductRewardEntry");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "BuildDeckSeriesRewardEntry");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerBuildDeckProductPurchase");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "BuildDeckProducts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "BuildDeckSeries");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,6 +540,100 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("Battlefields");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.BuildDeckProductEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DeckCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("FeaturedCardId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("IntroPriceCrystal")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("IntroPriceRupy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("LeaderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ProductNameKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PurchaseNumMax")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("RegularPriceCrystal")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("RegularPriceRupy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("BuildDeckProducts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DrumrollPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("IntroKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("NameKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TitlePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("BuildDeckSeries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.CardCosmeticReward", b =>
|
||||
{
|
||||
b.Property<long>("CardId")
|
||||
@@ -1975,6 +2069,125 @@ namespace SVSim.Database.Migrations
|
||||
b.Navigation("World");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.BuildDeckProductEntry", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.BuildDeckSeriesEntry", "Series")
|
||||
.WithMany("Products")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsMany("SVSim.Database.Models.BuildDeckProductCardEntry", "Cards", b1 =>
|
||||
{
|
||||
b1.Property<int>("BuildDeckProductEntryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<long>("CardId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<bool>("IsSpot")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<int>("Number")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("BuildDeckProductEntryId", "Id");
|
||||
|
||||
b1.ToTable("BuildDeckProductCardEntry");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BuildDeckProductEntryId");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Models.BuildDeckProductRewardEntry", "Rewards", b1 =>
|
||||
{
|
||||
b1.Property<int>("BuildDeckProductEntryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<int>("MessageId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<long>("RewardDetailId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("RewardIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("RewardNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("RewardType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("BuildDeckProductEntryId", "Id");
|
||||
|
||||
b1.ToTable("BuildDeckProductRewardEntry");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BuildDeckProductEntryId");
|
||||
});
|
||||
|
||||
b.Navigation("Cards");
|
||||
|
||||
b.Navigation("Rewards");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b =>
|
||||
{
|
||||
b.OwnsMany("SVSim.Database.Models.BuildDeckSeriesRewardEntry", "SeriesRewards", b1 =>
|
||||
{
|
||||
b1.Property<int>("BuildDeckSeriesEntryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<int>("ItemIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("MessageId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<long>("RewardDetailId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("RewardNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("RewardType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("TierIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("BuildDeckSeriesEntryId", "Id");
|
||||
|
||||
b1.ToTable("BuildDeckSeriesRewardEntry");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BuildDeckSeriesEntryId");
|
||||
});
|
||||
|
||||
b.Navigation("SeriesRewards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.CardCosmeticReward", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card")
|
||||
@@ -2322,6 +2535,34 @@ namespace SVSim.Database.Migrations
|
||||
b1.Navigation("Viewer");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Models.ViewerBuildDeckProductPurchase", "BuildDeckPurchases", b1 =>
|
||||
{
|
||||
b1.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<int>("ProductId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("PurchaseCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("ViewerId", "Id");
|
||||
|
||||
b1.HasIndex("ViewerId", "ProductId")
|
||||
.IsUnique();
|
||||
|
||||
b1.ToTable("ViewerBuildDeckProductPurchase");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ViewerId");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Models.ViewerClassData", "Classes", b1 =>
|
||||
{
|
||||
b1.Property<long>("ViewerId")
|
||||
@@ -2523,6 +2764,8 @@ namespace SVSim.Database.Migrations
|
||||
.HasForeignKey("ViewerId");
|
||||
});
|
||||
|
||||
b.Navigation("BuildDeckPurchases");
|
||||
|
||||
b.Navigation("Cards");
|
||||
|
||||
b.Navigation("Classes");
|
||||
@@ -2558,6 +2801,11 @@ namespace SVSim.Database.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b =>
|
||||
{
|
||||
b.Navigation("Products");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ClassEntry", b =>
|
||||
{
|
||||
b.Navigation("LeaderSkins");
|
||||
|
||||
17
SVSim.Database/Models/BuildDeckProductCardEntry.cs
Normal file
17
SVSim.Database/Models/BuildDeckProductCardEntry.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One card in a prebuilt-deck product's 40-card list. Owned by BuildDeckProductEntry.
|
||||
/// Shape mirrors `build_deck_package_master.csv` rows: (ProductId, CardId, Number, IsSpot).
|
||||
/// IsSpot=true marks the special prize/featured cards rendered in the separate _spotCardRoot
|
||||
/// panel by BuildDeckProductDetail.cs.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class BuildDeckProductCardEntry
|
||||
{
|
||||
public long CardId { get; set; }
|
||||
public int Number { get; set; }
|
||||
public bool IsSpot { get; set; }
|
||||
}
|
||||
32
SVSim.Database/Models/BuildDeckProductEntry.cs
Normal file
32
SVSim.Database/Models/BuildDeckProductEntry.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One purchasable prebuilt-deck product. PK = wire product_id. FK SeriesId.
|
||||
/// Pricing columns are nullable; either Crystal or Rupy pair (or both, both zero for free) must
|
||||
/// be populated for an enabled product. The Intro/Regular pair captures the two-tier pricing
|
||||
/// pattern: Intro applies to the first purchase, Regular to subsequent. For PurchaseNumMax=1
|
||||
/// products, Regular stays null and only Intro is ever served.
|
||||
/// </summary>
|
||||
public class BuildDeckProductEntry : BaseEntity<int>
|
||||
{
|
||||
public int SeriesId { get; set; }
|
||||
public int LeaderId { get; set; }
|
||||
public string DeckCode { get; set; } = string.Empty;
|
||||
public string ProductNameKey { get; set; } = string.Empty; // BDPN_*
|
||||
public long FeaturedCardId { get; set; }
|
||||
public int PurchaseNumMax { get; set; }
|
||||
|
||||
public int? IntroPriceCrystal { get; set; }
|
||||
public int? RegularPriceCrystal { get; set; }
|
||||
public int? IntroPriceRupy { get; set; }
|
||||
public int? RegularPriceRupy { get; set; }
|
||||
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
public List<BuildDeckProductCardEntry> Cards { get; set; } = new();
|
||||
public List<BuildDeckProductRewardEntry> Rewards { get; set; } = new();
|
||||
|
||||
public BuildDeckSeriesEntry? Series { get; set; }
|
||||
}
|
||||
18
SVSim.Database/Models/BuildDeckProductRewardEntry.cs
Normal file
18
SVSim.Database/Models/BuildDeckProductRewardEntry.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One per-buy reward attached to a prebuilt-deck product. Owned by BuildDeckProductEntry.
|
||||
/// Wire shape: one entry of the product-level `rewards` dict in /build_deck/info, keyed by
|
||||
/// RewardIndex (the wire string keys "1","2","3").
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class BuildDeckProductRewardEntry
|
||||
{
|
||||
public int RewardIndex { get; set; }
|
||||
public int RewardType { get; set; } // Wizard.UserGoods.Type
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
public int MessageId { get; set; }
|
||||
}
|
||||
22
SVSim.Database/Models/BuildDeckSeriesEntry.cs
Normal file
22
SVSim.Database/Models/BuildDeckSeriesEntry.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One prebuilt-deck series ("Structure Deck Set 7", "Trial 19", etc.). PK = wire series_id.
|
||||
/// IsEnabled gates whether /build_deck/info renders this series — disabled rows are placeholder
|
||||
/// stubs created from the client CSV until we capture a /info response that enriches them.
|
||||
/// </summary>
|
||||
public class BuildDeckSeriesEntry : BaseEntity<int>
|
||||
{
|
||||
public int OrderIndex { get; set; } // wire order_id; controls display order
|
||||
public string NameKey { get; set; } = string.Empty; // BDSSN_*
|
||||
public string IntroKey { get; set; } = string.Empty; // BDSI_*
|
||||
public string TitlePath { get; set; } = string.Empty;
|
||||
public string DrumrollPath { get; set; } = string.Empty;
|
||||
public bool IsNew { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
public List<BuildDeckSeriesRewardEntry> SeriesRewards { get; set; } = new();
|
||||
public List<BuildDeckProductEntry> Products { get; set; } = new();
|
||||
}
|
||||
20
SVSim.Database/Models/BuildDeckSeriesRewardEntry.cs
Normal file
20
SVSim.Database/Models/BuildDeckSeriesRewardEntry.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One tier-reward item attached to a prebuilt-deck series. Owned by BuildDeckSeriesEntry.
|
||||
/// Wire shape: flattened from /build_deck/info's `series_rewards` dict — each tier (keyed
|
||||
/// by total-purchases-from-series threshold) carries a list of rewards; this row is one
|
||||
/// (TierIndex, ItemIndex) cell.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class BuildDeckSeriesRewardEntry
|
||||
{
|
||||
public int TierIndex { get; set; } // 1, 2, 3, ... — unlock threshold
|
||||
public int ItemIndex { get; set; } // ordinal within tier
|
||||
public int RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
public int MessageId { get; set; }
|
||||
}
|
||||
@@ -57,6 +57,8 @@ public class Viewer : BaseEntity<long>
|
||||
|
||||
public List<ViewerPackOpenCount> PackOpenCounts { get; set; } = new List<ViewerPackOpenCount>();
|
||||
|
||||
public List<ViewerBuildDeckProductPurchase> BuildDeckPurchases { get; set; } = new List<ViewerBuildDeckProductPurchase>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Navigation Properties
|
||||
|
||||
14
SVSim.Database/Models/ViewerBuildDeckProductPurchase.cs
Normal file
14
SVSim.Database/Models/ViewerBuildDeckProductPurchase.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Per-viewer, per-product purchase counter. Owned collection on Viewer.
|
||||
/// Unique (ViewerId, ProductId) enforced in SVSimDbContext per project_owned_collection_unique_index.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class ViewerBuildDeckProductPurchase
|
||||
{
|
||||
public int ProductId { get; set; }
|
||||
public int PurchaseCount { get; set; }
|
||||
}
|
||||
67
SVSim.Database/Repositories/BuildDeck/BuildDeckRepository.cs
Normal file
67
SVSim.Database/Repositories/BuildDeck/BuildDeckRepository.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.BuildDeck;
|
||||
|
||||
public class BuildDeckRepository : IBuildDeckRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public BuildDeckRepository(SVSimDbContext db) { _db = db; }
|
||||
|
||||
public async Task<List<BuildDeckSeriesEntry>> GetEnabledCatalog(int addSeriesId)
|
||||
{
|
||||
var q = _db.BuildDeckSeries
|
||||
.Include(s => s.SeriesRewards)
|
||||
.Include(s => s.Products.Where(p => p.IsEnabled))
|
||||
.ThenInclude(p => p.Cards)
|
||||
.Include(s => s.Products.Where(p => p.IsEnabled))
|
||||
.ThenInclude(p => p.Rewards)
|
||||
.Where(s => s.IsEnabled)
|
||||
.AsSplitQuery();
|
||||
|
||||
if (addSeriesId != 0)
|
||||
{
|
||||
q = q.Where(s => s.Id == addSeriesId);
|
||||
}
|
||||
|
||||
var list = await q.ToListAsync();
|
||||
list.Sort((a, b) => b.OrderIndex.CompareTo(a.OrderIndex));
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<BuildDeckProductEntry?> GetProduct(int productId) =>
|
||||
await _db.BuildDeckProducts
|
||||
.Include(p => p.Cards)
|
||||
.Include(p => p.Rewards)
|
||||
.Include(p => p.Series).ThenInclude(s => s!.SeriesRewards)
|
||||
.Include(p => p.Series).ThenInclude(s => s!.Products)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(p => p.Id == productId);
|
||||
|
||||
public async Task<Dictionary<int, ViewerBuildDeckProductPurchase>> GetPurchasesForViewer(long viewerId)
|
||||
{
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.BuildDeckPurchases)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
return viewer?.BuildDeckPurchases.ToDictionary(p => p.ProductId) ?? new();
|
||||
}
|
||||
|
||||
public async Task<int> IncrementPurchaseCount(long viewerId, int productId)
|
||||
{
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.BuildDeckPurchases)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
var row = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == productId);
|
||||
if (row is null)
|
||||
{
|
||||
row = new ViewerBuildDeckProductPurchase { ProductId = productId, PurchaseCount = 1 };
|
||||
viewer.BuildDeckPurchases.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.PurchaseCount += 1;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
return row.PurchaseCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.BuildDeck;
|
||||
|
||||
public interface IBuildDeckRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Load enabled series (filtered by addSeriesId when non-zero) with all owned children
|
||||
/// for /build_deck/info. Series and per-series products are sorted by OrderIndex desc.
|
||||
/// </summary>
|
||||
Task<List<BuildDeckSeriesEntry>> GetEnabledCatalog(int addSeriesId);
|
||||
|
||||
/// <summary>
|
||||
/// Load a single product (with Series + Cards + Rewards + Series.SeriesRewards) by id.
|
||||
/// Returns null if absent. Used by /build_deck/buy and /build_deck/get_purchase_count.
|
||||
/// </summary>
|
||||
Task<BuildDeckProductEntry?> GetProduct(int productId);
|
||||
|
||||
/// <summary>
|
||||
/// Per-viewer purchase counter snapshot. Key = product_id.
|
||||
/// </summary>
|
||||
Task<Dictionary<int, ViewerBuildDeckProductPurchase>> GetPurchasesForViewer(long viewerId);
|
||||
|
||||
/// <summary>
|
||||
/// Increment the (ViewerId, ProductId) purchase counter by 1 (insert if absent).
|
||||
/// Returns the new total.
|
||||
/// </summary>
|
||||
Task<int> IncrementPurchaseCount(long viewerId, int productId);
|
||||
}
|
||||
@@ -59,6 +59,8 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<SpecialDeckFormatEntry> SpecialDeckFormats => Set<SpecialDeckFormatEntry>();
|
||||
public DbSet<PaymentItemEntry> PaymentItems => Set<PaymentItemEntry>();
|
||||
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
|
||||
public DbSet<BuildDeckSeriesEntry> BuildDeckSeries => Set<BuildDeckSeriesEntry>();
|
||||
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>();
|
||||
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
|
||||
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
||||
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
||||
@@ -141,6 +143,23 @@ public class SVSimDbContext : DbContext
|
||||
b.HasIndex("ViewerId", "ItemId").IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.BuildDeckPurchases, b =>
|
||||
{
|
||||
b.HasIndex("ViewerId", "ProductId").IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<BuildDeckSeriesEntry>().OwnsMany(s => s.SeriesRewards);
|
||||
modelBuilder.Entity<BuildDeckProductEntry>().OwnsMany(p => p.Cards);
|
||||
modelBuilder.Entity<BuildDeckProductEntry>().OwnsMany(p => p.Rewards);
|
||||
|
||||
modelBuilder.Entity<BuildDeckProductEntry>()
|
||||
.HasOne(p => p.Series)
|
||||
.WithMany(s => s.Products)
|
||||
.HasForeignKey(p => p.SeriesId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<BuildDeckProductEntry>().HasIndex(p => p.SeriesId);
|
||||
|
||||
modelBuilder.Entity<CardCosmeticReward>(b =>
|
||||
{
|
||||
b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId });
|
||||
|
||||
@@ -14,14 +14,26 @@ namespace SVSim.Database.Services;
|
||||
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
|
||||
|
||||
/// <summary>
|
||||
/// Single canonical grant primitive. Switch on <see cref="UserGoodsType"/>, mutate the
|
||||
/// appropriate viewer collection / <see cref="ViewerCurrency"/> field, and return the
|
||||
/// wire-shape entries the caller should embed in its response's reward_list.
|
||||
/// Single canonical grant primitive for every <see cref="UserGoodsType"/> the server hands to a
|
||||
/// viewer. Switch on the type, mutate the appropriate viewer collection / <see cref="ViewerCurrency"/>
|
||||
/// field, return the wire-shape entries to embed in the response's <c>reward_list</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>DO NOT reimplement reward dispatch in a controller or new helper.</b> This service handles
|
||||
/// RedEther, Crystal, Item, Card (with <see cref="CardCosmeticReward"/> cascade), Sleeve, Emblem,
|
||||
/// Degree, Rupy, Skin, MyPageBG — everything except SpotCard (TODO). Endpoint code that takes a
|
||||
/// list of <c>(type, id, num)</c> tuples should iterate and call <see cref="ApplyAsync"/>
|
||||
/// per tuple — never switch on type yourself, never filter to "only card-typed rewards", never
|
||||
/// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the
|
||||
/// EmulatedEntrypoint project, the first pass at /build_deck/buy) all silently dropped subsets of
|
||||
/// types and produced the same bug: wire reward visible but viewer's collection unchanged. When a
|
||||
/// new reward type comes up, add a case here. See <c>feedback_reward_grant_service</c> memory.
|
||||
/// </para>
|
||||
///
|
||||
/// Card grants additionally run the <see cref="CardCosmeticReward"/> cascade: any cosmetic
|
||||
/// associated with the granted card that the viewer doesn't yet own is granted too, and
|
||||
/// produces an additional entry in the returned list. That's why the return type is a list:
|
||||
/// most types produce one entry, Card produces 1 + N.
|
||||
/// associated with the granted card that the viewer doesn't yet own is granted too, and produces
|
||||
/// an additional entry in the returned list. That's why the return type is a list: most types
|
||||
/// produce one entry, Card produces 1 + N.
|
||||
///
|
||||
/// Caller is responsible for <see cref="SVSimDbContext.SaveChangesAsync(System.Threading.CancellationToken)"/> —
|
||||
/// this service only mutates the in-memory graph so a controller can stack several grants in
|
||||
|
||||
329
SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs
Normal file
329
SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.BuildDeck;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /build_deck/* — the in-game "Structure Deck" prebuilt-deck shop. Catalog +
|
||||
/// purchase + per-product purchase counter refresh. See
|
||||
/// docs/superpowers/specs/2026-05-26-prebuilt-decks-design.md.
|
||||
/// </summary>
|
||||
[Route("build_deck")]
|
||||
public class BuildDeckController : SVSimController
|
||||
{
|
||||
private readonly IBuildDeckRepository _repo;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
|
||||
public BuildDeckController(
|
||||
IBuildDeckRepository repo,
|
||||
SVSimDbContext db,
|
||||
RewardGrantService rewards)
|
||||
{
|
||||
_repo = repo;
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the viewer with the full cosmetic / inventory graph + BuildDeckPurchases. This is
|
||||
/// the single load /build_deck/buy makes; every subsequent mutation operates on the returned
|
||||
/// instance and the controller saves once at the end.
|
||||
/// </summary>
|
||||
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.BuildDeckPurchases)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
// The wire shape for /build_deck/info has `data` as a bare collection of series, not a
|
||||
// DTO with a `series_list` field. The client (BuildDeckPurchaseInfoTask.Parse) iterates
|
||||
// `data` directly via numeric indexer:
|
||||
// for (int i = 0; i < data.Count; i++) data[i]["series_id"].ToInt();
|
||||
// So `data` must be either an array OR an object whose values are series. Wrapping in
|
||||
// `{series_list: [...]}` breaks the iteration: `data.Count` is 1 and `data[0]` is the
|
||||
// inner array, so `data[0]["series_id"]` throws "Instance of JsonData is not a dictionary".
|
||||
// We return a bare array — simpler than the dict-keyed-by-order_id shape prod emits, and
|
||||
// LitJson's numeric indexer iterates both shapes identically.
|
||||
[HttpPost("info")]
|
||||
public async Task<ActionResult<List<BuildDeckSeriesDto>>> Info(BuildDeckInfoRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var series = await _repo.GetEnabledCatalog(request.AddSeriesId);
|
||||
var purchases = await _repo.GetPurchasesForViewer(viewerId);
|
||||
|
||||
return series.Select(s => ToSeriesDto(s, purchases)).ToList();
|
||||
}
|
||||
|
||||
private static BuildDeckSeriesDto ToSeriesDto(
|
||||
BuildDeckSeriesEntry s,
|
||||
IReadOnlyDictionary<int, ViewerBuildDeckProductPurchase> purchases)
|
||||
{
|
||||
int totalSeriesPurchases = s.Products
|
||||
.Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0);
|
||||
|
||||
return new BuildDeckSeriesDto
|
||||
{
|
||||
SeriesId = s.Id,
|
||||
OrderId = s.OrderIndex,
|
||||
IsNew = s.IsNew,
|
||||
Products = s.Products
|
||||
.OrderBy(p => p.Id)
|
||||
.Select(p => ToProductDto(p, purchases))
|
||||
.ToList(),
|
||||
SeriesRewards = GroupSeriesRewards(s.SeriesRewards, totalSeriesPurchases),
|
||||
};
|
||||
}
|
||||
|
||||
private static BuildDeckProductDto ToProductDto(
|
||||
BuildDeckProductEntry p,
|
||||
IReadOnlyDictionary<int, ViewerBuildDeckProductPurchase> purchases)
|
||||
{
|
||||
int current = purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0;
|
||||
bool isFirstPrice = current == 0;
|
||||
int? priceCrystal = SelectPrice(isFirstPrice, p.IntroPriceCrystal, p.RegularPriceCrystal);
|
||||
int? priceRupy = SelectPrice(isFirstPrice, p.IntroPriceRupy, p.RegularPriceRupy);
|
||||
|
||||
return new BuildDeckProductDto
|
||||
{
|
||||
ProductId = p.Id,
|
||||
ProductName = p.ProductNameKey,
|
||||
LeaderId = p.LeaderId,
|
||||
DeckCode = p.DeckCode,
|
||||
FeaturedCardId = p.FeaturedCardId,
|
||||
PurchaseNumMax = p.PurchaseNumMax,
|
||||
PurchaseNumCurrent = current,
|
||||
IsFirstPrice = isFirstPrice,
|
||||
PriceCrystal = priceCrystal,
|
||||
PriceRupy = priceRupy,
|
||||
Rewards = p.Rewards
|
||||
.OrderBy(r => r.RewardIndex)
|
||||
.Select(r => new BuildDeckProductRewardDto
|
||||
{
|
||||
RewardType = r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
MessageId = r.MessageId,
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private static int? SelectPrice(bool isFirstPrice, int? intro, int? regular)
|
||||
{
|
||||
if (isFirstPrice) return intro ?? regular; // fall back when only one tier known
|
||||
return regular ?? intro;
|
||||
}
|
||||
|
||||
private static List<BuildDeckSeriesRewardTierDto> GroupSeriesRewards(
|
||||
IReadOnlyList<BuildDeckSeriesRewardEntry> rows,
|
||||
int totalSeriesPurchases)
|
||||
{
|
||||
return rows
|
||||
.GroupBy(r => r.TierIndex)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new BuildDeckSeriesRewardTierDto
|
||||
{
|
||||
IsGet = totalSeriesPurchases >= g.Key,
|
||||
RewardList = g.OrderBy(r => r.ItemIndex).Select(r => new BuildDeckProductRewardDto
|
||||
{
|
||||
RewardType = r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
MessageId = r.MessageId,
|
||||
}).ToList(),
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
[HttpPost("buy")]
|
||||
public async Task<ActionResult<BuildDeckBuyResponse>> Buy(BuildDeckBuyRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var product = await _repo.GetProduct(request.ProductId);
|
||||
if (product is null) return NotFound(new { error = "unknown_product" });
|
||||
|
||||
if (!product.IsEnabled || product.Series is not { IsEnabled: true })
|
||||
return BadRequest(new { error = "product_not_available" });
|
||||
|
||||
if (request.SalesType is 3)
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_currency_path_not_implemented" });
|
||||
if (request.SalesType is < 0 or > 3)
|
||||
return BadRequest(new { error = "invalid_sales_type" });
|
||||
|
||||
var purchases = await _repo.GetPurchasesForViewer(viewerId);
|
||||
int currentCount = purchases.TryGetValue(product.Id, out var pp) ? pp.PurchaseCount : 0;
|
||||
if (currentCount >= product.PurchaseNumMax)
|
||||
return BadRequest(new { error = "purchase_limit_reached" });
|
||||
|
||||
bool isFirstPrice = currentCount == 0;
|
||||
int? priceCrystal = SelectPrice(isFirstPrice, product.IntroPriceCrystal, product.RegularPriceCrystal);
|
||||
int? priceRupy = SelectPrice(isFirstPrice, product.IntroPriceRupy, product.RegularPriceRupy);
|
||||
|
||||
// Currency validation
|
||||
switch (request.SalesType)
|
||||
{
|
||||
case 0: // free
|
||||
if (!(product.IntroPriceCrystal == 0 && product.IntroPriceRupy == 0))
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
break;
|
||||
case 1: // crystal
|
||||
if (priceCrystal is null)
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
break;
|
||||
case 2: // rupy
|
||||
if (priceRupy is null)
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
break;
|
||||
}
|
||||
|
||||
// Single viewer load with the full graph — every subsequent mutation (currency debit,
|
||||
// purchase counter, card grants, cosmetic grants) operates on this one in-memory instance
|
||||
// so we can save once at the end.
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
|
||||
// Debit + post-state currency entry
|
||||
if (request.SalesType == 1)
|
||||
{
|
||||
ulong cost = (ulong)priceCrystal!.Value;
|
||||
if (viewer.Currency.Crystals < cost)
|
||||
return BadRequest(new { error = "insufficient_crystals" });
|
||||
viewer.Currency.Crystals -= cost;
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals });
|
||||
}
|
||||
else if (request.SalesType == 2)
|
||||
{
|
||||
ulong cost = (ulong)priceRupy!.Value;
|
||||
if (viewer.Currency.Rupees < cost)
|
||||
return BadRequest(new { error = "insufficient_rupees" });
|
||||
viewer.Currency.Rupees -= cost;
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees });
|
||||
}
|
||||
// sales_type == 0 (free): no debit, no currency entry
|
||||
|
||||
// Compute series purchase total BEFORE this buy
|
||||
int prevSeriesCount = product.Series!.Products
|
||||
.Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0);
|
||||
int newSeriesCount = prevSeriesCount + 1;
|
||||
|
||||
// Increment purchase counter directly on the tracked viewer (we already loaded
|
||||
// BuildDeckPurchases via LoadViewerGraphAsync). The repo's IncrementPurchaseCount would
|
||||
// re-attach to the same instance and trigger an extra save — inlining keeps the
|
||||
// controller's single-save model intact.
|
||||
var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id);
|
||||
if (purchaseRow is null)
|
||||
viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 });
|
||||
else
|
||||
purchaseRow.PurchaseCount += 1;
|
||||
|
||||
// Grant the 40 deck cards. Bucket by CardId so duplicate (CardId, IsSpot) rows don't
|
||||
// emit redundant reward_list entries — ApplyAsync(Card, ...) runs the cosmetic cascade
|
||||
// and returns a post-state-total entry per call.
|
||||
var deckGrants = product.Cards
|
||||
.GroupBy(c => c.CardId)
|
||||
.Select(g => (UserGoodsType.Card, g.Key, g.Sum(c => c.Number)));
|
||||
await ApplyRewardsAsync(viewer, deckGrants, rewardList);
|
||||
|
||||
// Per-buy rewards from the product catalog: sleeve, emblem, skin, sometimes extra cards
|
||||
// (Set 4 grants 3 copies of the featured card as a type=5 reward).
|
||||
await ApplyRewardsAsync(viewer, product.Rewards
|
||||
.OrderBy(r => r.RewardIndex)
|
||||
.Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)),
|
||||
rewardList);
|
||||
|
||||
// Series-reward tier crossings: tiers where prevSeriesCount < TierIndex <= newSeriesCount.
|
||||
// Captured tiers include type 4 (Item), 5 (Card), 6 (Sleeve), 7 (Emblem) — granting them
|
||||
// all uniformly avoids the earlier card-only path that dropped non-card tier rewards.
|
||||
var crossedTiers = product.Series.SeriesRewards
|
||||
.Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount)
|
||||
.GroupBy(r => r.TierIndex)
|
||||
.OrderBy(g => g.Key)
|
||||
.ToList();
|
||||
|
||||
var seriesRewards = new List<BuildDeckProductRewardDto>();
|
||||
foreach (var tier in crossedTiers)
|
||||
{
|
||||
await ApplyRewardsAsync(viewer, tier
|
||||
.OrderBy(r => r.ItemIndex)
|
||||
.Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)),
|
||||
rewardList);
|
||||
|
||||
foreach (var item in tier.OrderBy(r => r.ItemIndex))
|
||||
{
|
||||
seriesRewards.Add(new BuildDeckProductRewardDto
|
||||
{
|
||||
RewardType = item.RewardType,
|
||||
RewardDetailId = item.RewardDetailId,
|
||||
RewardNumber = item.RewardNumber,
|
||||
MessageId = item.MessageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new BuildDeckBuyResponse
|
||||
{
|
||||
RewardList = rewardList,
|
||||
SeriesRewards = seriesRewards,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches each (type, id, num) tuple through <see cref="RewardGrantService.ApplyAsync"/>
|
||||
/// and appends the resulting wire entries to <paramref name="rewardList"/>. Caller saves.
|
||||
/// </summary>
|
||||
private async Task ApplyRewardsAsync(
|
||||
Viewer viewer,
|
||||
IEnumerable<(UserGoodsType Type, long DetailId, int Number)> rewards,
|
||||
List<RewardListEntry> rewardList)
|
||||
{
|
||||
foreach (var (type, detailId, number) in rewards)
|
||||
{
|
||||
var granted = await _rewards.ApplyAsync(viewer, type, detailId, number);
|
||||
foreach (var g in granted)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("get_purchase_count")]
|
||||
public async Task<ActionResult<BuildDeckGetPurchaseCountResponse>> GetPurchaseCount(
|
||||
BuildDeckGetPurchaseCountRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var product = await _repo.GetProduct(request.ProductId);
|
||||
if (product is null) return NotFound(new { error = "unknown_product" });
|
||||
|
||||
var purchases = await _repo.GetPurchasesForViewer(viewerId);
|
||||
int current = purchases.TryGetValue(request.ProductId, out var p) ? p.PurchaseCount : 0;
|
||||
|
||||
return new BuildDeckGetPurchaseCountResponse
|
||||
{
|
||||
PurchaseNumCurrent = current,
|
||||
PurchaseNumMax = product.PurchaseNumMax,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,18 @@ public class DeckController : SVSimController
|
||||
private async Task<DeckListResponse> BuildDeckListResponseAsync(long viewerId, Format requestFormat)
|
||||
{
|
||||
var defaultDecks = await _globalsRepository.GetDefaultDecks();
|
||||
var leaderSkinSettings = await _globalsRepository.GetDefaultLeaderSkinSettings();
|
||||
|
||||
// user_leader_skin_setting_list is PER-VIEWER (the wire `user_` prefix is honest, despite
|
||||
// the misleading docstring on DefaultLeaderSkinSetting). Source it from the viewer's
|
||||
// ViewerClassData rows, matching how /load/index's user_class_list reads them. The global
|
||||
// DefaultLeaderSkinSettings table is now used only as initial seed values for fresh
|
||||
// viewers (ViewerRepository.RegisterViewer); the per-class current skin is on
|
||||
// viewer.Classes[i].LeaderSkin and gets mutated by /leader_skin/update.
|
||||
var viewerClasses = await _dbContext.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Classes)
|
||||
.Select(c => new { c.Class.Id, LeaderSkinId = c.LeaderSkin.Id })
|
||||
.ToListAsync();
|
||||
|
||||
var response = new DeckListResponse
|
||||
{
|
||||
@@ -112,13 +123,13 @@ public class DeckController : SVSimController
|
||||
IsAvailableDeck = 1,
|
||||
MaintenanceCardIds = new(),
|
||||
}),
|
||||
UserLeaderSkinSettingList = leaderSkinSettings.ToDictionary(
|
||||
s => s.Id.ToString(),
|
||||
s => new DefaultLeaderSkinSetting
|
||||
UserLeaderSkinSettingList = viewerClasses.ToDictionary(
|
||||
vc => vc.Id.ToString(),
|
||||
vc => new DefaultLeaderSkinSetting
|
||||
{
|
||||
ClassId = s.ClassId,
|
||||
IsRandomLeaderSkin = s.IsRandomLeaderSkin,
|
||||
LeaderSkinId = s.LeaderSkinId,
|
||||
ClassId = vc.Id,
|
||||
IsRandomLeaderSkin = 0, // random-skin mode (per-class shuffle pool) not yet persisted
|
||||
LeaderSkinId = vc.LeaderSkinId,
|
||||
}),
|
||||
MaintenanceCardList = new(), // sourced from same place as /load/index when wired
|
||||
};
|
||||
|
||||
64
SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs
Normal file
64
SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /leader_skin/* — per-class "active leader skin" preference. The per-CLASS setting is the
|
||||
/// fallback used when a deck has <c>leader_skin_id == 0</c>; per-deck overrides go through
|
||||
/// /deck/update_leader_skin instead.
|
||||
/// </summary>
|
||||
[Route("leader_skin")]
|
||||
public class LeaderSkinController : SVSimController
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
|
||||
public LeaderSkinController(SVSimDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpPost("set")]
|
||||
public async Task<ActionResult<LeaderSkinSetResponse>> Set(LeaderSkinSetRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
if (request.IsRandomLeaderSkin)
|
||||
{
|
||||
// Random-skin mode needs a per-viewer per-class shuffle pool, which we don't
|
||||
// persist yet (ViewerClassData has no list field for it). Punt for now.
|
||||
return StatusCode(StatusCodes.Status501NotImplemented,
|
||||
new { error = "random_leader_skin_not_implemented" });
|
||||
}
|
||||
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.Classes).ThenInclude(c => c.Class)
|
||||
.Include(v => v.Classes).ThenInclude(c => c.LeaderSkin)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
if (viewer is null) return Unauthorized();
|
||||
|
||||
var classData = viewer.Classes.FirstOrDefault(c => c.Class.Id == request.ClassId);
|
||||
if (classData is null) return BadRequest(new { error = "unknown_class" });
|
||||
|
||||
// Skin must (a) exist in the catalog, (b) match the target class, (c) be owned by the viewer.
|
||||
var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
|
||||
if (skin is null) return BadRequest(new { error = "unknown_skin" });
|
||||
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
|
||||
if (viewer.LeaderSkins.All(s => s.Id != skin.Id))
|
||||
return BadRequest(new { error = "skin_not_owned" });
|
||||
|
||||
classData.LeaderSkin = skin;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new LeaderSkinSetResponse
|
||||
{
|
||||
IsRandomLeaderSkin = false,
|
||||
LeaderSkinId = skin.Id,
|
||||
LeaderSkinIdList = new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck;
|
||||
|
||||
/// <summary>
|
||||
/// /build_deck/buy request body. sales_type is ShopCommonUtility.SalesType:
|
||||
/// 0=free, 1=crystal, 2=rupy, 3=ticket (v1: 3 returns 501).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BuildDeckBuyRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("product_id")]
|
||||
[Key("product_id")]
|
||||
public int ProductId { get; set; }
|
||||
|
||||
[JsonPropertyName("sales_type")]
|
||||
[Key("sales_type")]
|
||||
public int SalesType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class BuildDeckGetPurchaseCountRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("product_id")]
|
||||
[Key("product_id")]
|
||||
public int ProductId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck;
|
||||
|
||||
/// <summary>
|
||||
/// /build_deck/info request body. <c>add_series_id == 0</c> means "return all"; non-zero filters
|
||||
/// to the single matching series (used by the client to re-fetch after a purchase).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BuildDeckInfoRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("add_series_id")]
|
||||
[Key("add_series_id")]
|
||||
public int AddSeriesId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
|
||||
|
||||
/// <summary>
|
||||
/// POST /leader_skin/set — the per-class "current leader skin" preference used as a fallback
|
||||
/// when a deck has <c>leader_skin_id == 0</c>. Two modes:
|
||||
/// - Non-random: <c>is_random_leader_skin=false</c>, <c>leader_skin_id</c> is the chosen skin id.
|
||||
/// - Random: <c>is_random_leader_skin=true</c>, <c>leader_skin_id_list</c> is the shuffle pool
|
||||
/// (server picks per-match). Random mode is not implemented in v1 (returns 501).
|
||||
/// Source: <c>Wizard/LeaderSkinUpdateTask.cs</c>.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class LeaderSkinSetRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("class_id")]
|
||||
[Key("class_id")]
|
||||
public int ClassId { get; set; }
|
||||
|
||||
[JsonPropertyName("leader_skin_id")]
|
||||
[Key("leader_skin_id")]
|
||||
public int LeaderSkinId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_random_leader_skin")]
|
||||
[Key("is_random_leader_skin")]
|
||||
public bool IsRandomLeaderSkin { get; set; }
|
||||
|
||||
[JsonPropertyName("leader_skin_id_list")]
|
||||
[Key("leader_skin_id_list")]
|
||||
public int[] LeaderSkinIdList { get; set; } = Array.Empty<int>();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck;
|
||||
|
||||
/// <summary>
|
||||
/// /build_deck/buy response. reward_list items use reward_id/reward_num (driven by
|
||||
/// PlayerStaticData.UpdateHaveUserGoodsNumByJsonData with POST-STATE-TOTAL semantics);
|
||||
/// series_rewards items use reward_detail_id/reward_number — different naming, intentional.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BuildDeckBuyResponse
|
||||
{
|
||||
[JsonPropertyName("reward_list")]
|
||||
[Key("reward_list")]
|
||||
public List<RewardListEntry> RewardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("series_rewards")]
|
||||
[Key("series_rewards")]
|
||||
public List<BuildDeckProductRewardDto> SeriesRewards { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class BuildDeckGetPurchaseCountResponse
|
||||
{
|
||||
[JsonPropertyName("purchase_num_current")]
|
||||
[Key("purchase_num_current")]
|
||||
public int PurchaseNumCurrent { get; set; }
|
||||
|
||||
[JsonPropertyName("purchase_num_max")]
|
||||
[Key("purchase_num_max")]
|
||||
public int PurchaseNumMax { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck;
|
||||
|
||||
// /build_deck/info wire shape: the controller returns `List<BuildDeckSeriesDto>` directly so
|
||||
// `data` becomes a bare array `[{series_id:...},...]`. The client iterates `data` via numeric
|
||||
// indexer; a wrapper object like `{series_list:[...]}` would put the array one level deeper
|
||||
// and break the iteration. There is no BuildDeckInfoResponse wrapper type — the response IS
|
||||
// the series list.
|
||||
|
||||
[MessagePackObject]
|
||||
public class BuildDeckSeriesDto
|
||||
{
|
||||
[JsonPropertyName("series_id")]
|
||||
[Key("series_id")]
|
||||
public int SeriesId { get; set; }
|
||||
|
||||
[JsonPropertyName("order_id")]
|
||||
[Key("order_id")]
|
||||
public int OrderId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_new")]
|
||||
[Key("is_new")]
|
||||
public bool IsNew { get; set; }
|
||||
|
||||
[JsonPropertyName("products")]
|
||||
[Key("products")]
|
||||
public List<BuildDeckProductDto> Products { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("series_rewards")]
|
||||
[Key("series_rewards")]
|
||||
public List<BuildDeckSeriesRewardTierDto> SeriesRewards { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class BuildDeckProductDto
|
||||
{
|
||||
[JsonPropertyName("product_id")]
|
||||
[Key("product_id")]
|
||||
public int ProductId { get; set; }
|
||||
|
||||
[JsonPropertyName("product_name")]
|
||||
[Key("product_name")]
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("leader_id")]
|
||||
[Key("leader_id")]
|
||||
public int LeaderId { get; set; }
|
||||
|
||||
[JsonPropertyName("deck_code")]
|
||||
[Key("deck_code")]
|
||||
public string DeckCode { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("featured_card_id")]
|
||||
[Key("featured_card_id")]
|
||||
public long FeaturedCardId { get; set; }
|
||||
|
||||
[JsonPropertyName("purchase_num_max")]
|
||||
[Key("purchase_num_max")]
|
||||
public int PurchaseNumMax { get; set; }
|
||||
|
||||
[JsonPropertyName("purchase_num_current")]
|
||||
[Key("purchase_num_current")]
|
||||
public int PurchaseNumCurrent { get; set; }
|
||||
|
||||
[JsonPropertyName("is_first_price")]
|
||||
[Key("is_first_price")]
|
||||
public bool IsFirstPrice { get; set; }
|
||||
|
||||
[JsonPropertyName("rewards")]
|
||||
[Key("rewards")]
|
||||
public List<BuildDeckProductRewardDto> Rewards { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("sales_period_info")]
|
||||
[Key("sales_period_info")]
|
||||
public List<object> SalesPeriodInfo { get; set; } = new(); // always [] in v1
|
||||
|
||||
[JsonPropertyName("price_crystal")]
|
||||
[Key("price_crystal")]
|
||||
public int? PriceCrystal { get; set; }
|
||||
|
||||
[JsonPropertyName("price_rupy")]
|
||||
[Key("price_rupy")]
|
||||
public int? PriceRupy { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class BuildDeckProductRewardDto
|
||||
{
|
||||
[JsonPropertyName("reward_type")]
|
||||
[Key("reward_type")]
|
||||
public int RewardType { get; set; }
|
||||
|
||||
[JsonPropertyName("reward_detail_id")]
|
||||
[Key("reward_detail_id")]
|
||||
public long RewardDetailId { get; set; }
|
||||
|
||||
[JsonPropertyName("reward_number")]
|
||||
[Key("reward_number")]
|
||||
public int RewardNumber { get; set; }
|
||||
|
||||
[JsonPropertyName("message_id")]
|
||||
[Key("message_id")]
|
||||
public int MessageId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class BuildDeckSeriesRewardTierDto
|
||||
{
|
||||
[JsonPropertyName("reward_list")]
|
||||
[Key("reward_list")]
|
||||
public List<BuildDeckProductRewardDto> RewardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("is_get")]
|
||||
[Key("is_get")]
|
||||
public bool IsGet { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin;
|
||||
|
||||
/// <summary>
|
||||
/// Response shape for POST /leader_skin/set. Per <c>LeaderSkinUpdateTask.Parse</c>:
|
||||
/// - <c>is_random_leader_skin</c> echoes the mode the server actually applied.
|
||||
/// - <c>leader_skin_id</c> is only consumed by the client when random mode is on (it picks
|
||||
/// one of the pool to display). In non-random mode the client uses the request's id.
|
||||
/// - <c>leader_skin_id_list</c> is the active shuffle pool (empty for non-random).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class LeaderSkinSetResponse
|
||||
{
|
||||
[JsonPropertyName("is_random_leader_skin")]
|
||||
[Key("is_random_leader_skin")]
|
||||
public bool IsRandomLeaderSkin { get; set; }
|
||||
|
||||
[JsonPropertyName("leader_skin_id")]
|
||||
[Key("leader_skin_id")]
|
||||
public int LeaderSkinId { get; set; }
|
||||
|
||||
[JsonPropertyName("leader_skin_id_list")]
|
||||
[Key("leader_skin_id_list")]
|
||||
public List<int> LeaderSkinIdList { get; set; } = new();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Repositories.BuildDeck;
|
||||
using SVSim.Database.Repositories.Card;
|
||||
using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
@@ -73,6 +74,7 @@ public class Program
|
||||
builder.Services.AddTransient<IPuzzleCatalogRepository, PuzzleCatalogRepository>();
|
||||
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
|
||||
builder.Services.AddTransient<IPackRepository, PackRepository>();
|
||||
builder.Services.AddTransient<IBuildDeckRepository, BuildDeckRepository>();
|
||||
// Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle
|
||||
// pitfall. Cost: one indexed single-row query per section per request — trivial. No
|
||||
// in-process cache today; the IGameConfigService interface is shaped to allow one later.
|
||||
|
||||
441
SVSim.UnitTests/Controllers/BuildDeckControllerBuyTests.cs
Normal file
441
SVSim.UnitTests/Controllers/BuildDeckControllerBuyTests.cs
Normal file
@@ -0,0 +1,441 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
public class BuildDeckControllerBuyTests
|
||||
{
|
||||
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
/// <summary>
|
||||
/// Seeds: series 101 (enabled), one crystal-priced product 1 (intro=500/regular=750, max=3)
|
||||
/// containing 2 distinct cards (10001001 ×2, 10001002 ×1). Caller may set viewer crystals.
|
||||
/// </summary>
|
||||
private static async Task SeedCrystalProduct(SVSimTestFactory f, long viewerId, ulong crystals)
|
||||
{
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = 101, OrderIndex = 22, IsEnabled = true, NameKey = "BDSSN_test", IntroKey = "BDSI_test",
|
||||
Products =
|
||||
{
|
||||
new BuildDeckProductEntry
|
||||
{
|
||||
Id = 1, SeriesId = 101, LeaderId = 1, DeckCode = "pd0101",
|
||||
PurchaseNumMax = 3, IntroPriceCrystal = 500, RegularPriceCrystal = 750,
|
||||
IsEnabled = true,
|
||||
Cards =
|
||||
{
|
||||
new BuildDeckProductCardEntry { CardId = 10001001L, Number = 2, IsSpot = false },
|
||||
new BuildDeckProductCardEntry { CardId = 10001002L, Number = 1, IsSpot = false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Crystals = crystals;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedRupyProduct(SVSimTestFactory f, long viewerId, ulong rupees)
|
||||
{
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = 102, OrderIndex = 23, IsEnabled = true, NameKey = "BDSSN_rupy", IntroKey = "BDSI_rupy",
|
||||
Products =
|
||||
{
|
||||
new BuildDeckProductEntry
|
||||
{
|
||||
Id = 10, SeriesId = 102, LeaderId = 2, DeckCode = "pdR",
|
||||
PurchaseNumMax = 1, IntroPriceRupy = 100,
|
||||
IsEnabled = true,
|
||||
Cards = { new BuildDeckProductCardEntry { CardId = 10001001L, Number = 1, IsSpot = false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Rupees = rupees;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedFreeProduct(SVSimTestFactory f, long viewerId)
|
||||
{
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = 103, OrderIndex = 24, IsEnabled = true, NameKey = "BDSSN_free", IntroKey = "BDSI_free",
|
||||
Products =
|
||||
{
|
||||
new BuildDeckProductEntry
|
||||
{
|
||||
Id = 20, SeriesId = 103, LeaderId = 3, DeckCode = "pdF",
|
||||
PurchaseNumMax = 1, IntroPriceCrystal = 0, IntroPriceRupy = 0,
|
||||
IsEnabled = true,
|
||||
Cards = { new BuildDeckProductCardEntry { CardId = 10001003L, Number = 1, IsSpot = false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds: series 104 + product 100 with a per-buy sleeve reward (id 3000021, a real seeded
|
||||
/// sleeve master row). Used to verify the per-buy rewards path that drops sleeve/emblem/skin
|
||||
/// grants if the controller's Rewards iteration is missing.
|
||||
/// </summary>
|
||||
private static async Task SeedProductWithSleeveReward(SVSimTestFactory f, long viewerId)
|
||||
{
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = 104, OrderIndex = 19, IsEnabled = true, NameKey = "BDSSN_sleeve", IntroKey = "BDSI_sleeve",
|
||||
Products =
|
||||
{
|
||||
new BuildDeckProductEntry
|
||||
{
|
||||
Id = 100, SeriesId = 104, LeaderId = 1, DeckCode = "pd0104",
|
||||
PurchaseNumMax = 1, IntroPriceCrystal = 0, IntroPriceRupy = 0, // free
|
||||
IsEnabled = true,
|
||||
Cards = { new BuildDeckProductCardEntry { CardId = 10001001L, Number = 1, IsSpot = false } },
|
||||
Rewards =
|
||||
{
|
||||
new BuildDeckProductRewardEntry
|
||||
{
|
||||
RewardIndex = 1, RewardType = 6 /* Sleeve */,
|
||||
RewardDetailId = 3000021, RewardNumber = 1, MessageId = 51004,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Buy_grants_per_buy_sleeve_reward_to_viewer_collection()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedProductWithSleeveReward(factory, viewerId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":100,"sales_type":0}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.Sleeves).FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.Sleeves.Any(s => s.Id == 3000021), Is.True,
|
||||
"per-buy sleeve reward must land in viewer's owned collection");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var entries = doc.RootElement.GetProperty("reward_list");
|
||||
bool foundSleeve = false;
|
||||
for (int i = 0; i < entries.GetArrayLength(); i++)
|
||||
{
|
||||
var e = entries[i];
|
||||
if (e.GetProperty("reward_type").GetInt32() == 6 && e.GetProperty("reward_id").GetInt64() == 3000021)
|
||||
foundSleeve = true;
|
||||
}
|
||||
Assert.That(foundSleeve, Is.True, "reward_list must include the granted sleeve entry");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Crystal_buy_debits_intro_price_and_grants_cards()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedCrystalProduct(factory, viewerId, crystals: 1000);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers
|
||||
.Include(x => x.Cards).ThenInclude(c => c.Card)
|
||||
.Include(x => x.BuildDeckPurchases)
|
||||
.FirstAsync(x => x.Id == viewerId);
|
||||
|
||||
Assert.That(v.Currency.Crystals, Is.EqualTo(500UL), "1000 - 500 intro");
|
||||
Assert.That(v.Cards.Sum(c => c.Count), Is.EqualTo(3), "2 + 1 cards granted");
|
||||
Assert.That(v.BuildDeckPurchases.Single(p => p.ProductId == 1).PurchaseCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Crystal_buy_emits_post_state_total_for_crystals()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedCrystalProduct(factory, viewerId, crystals: 1000);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var rewardList = doc.RootElement.GetProperty("reward_list");
|
||||
bool foundCrystals = false;
|
||||
for (int i = 0; i < rewardList.GetArrayLength(); i++)
|
||||
{
|
||||
var e = rewardList[i];
|
||||
if (e.GetProperty("reward_type").GetInt32() == 2)
|
||||
{
|
||||
Assert.That(e.GetProperty("reward_num").GetInt32(), Is.EqualTo(500), "post-state crystals total");
|
||||
foundCrystals = true;
|
||||
}
|
||||
}
|
||||
Assert.That(foundCrystals, Is.True, "crystal entry must be in reward_list");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Returns_BadRequest_when_insufficient_crystals()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedCrystalProduct(factory, viewerId, crystals: 100);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Returns_BadRequest_for_disabled_product()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = 101, OrderIndex = 22, IsEnabled = true, NameKey = "x", IntroKey = "x",
|
||||
Products =
|
||||
{
|
||||
new BuildDeckProductEntry
|
||||
{
|
||||
Id = 999, SeriesId = 101, PurchaseNumMax = 1, IntroPriceCrystal = 500,
|
||||
IsEnabled = false,
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":999,"sales_type":1}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Returns_BadRequest_when_purchase_limit_reached()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedCrystalProduct(factory, viewerId, crystals: 10000);
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.BuildDeckPurchases).FirstAsync(x => x.Id == viewerId);
|
||||
v.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = 1, PurchaseCount = 3 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Returns_BadRequest_when_paying_in_unsupported_currency_for_product()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedCrystalProduct(factory, viewerId, crystals: 1000); // crystal-only product
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
// sales_type=2 (rupy) against a crystal-only product
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":2}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Returns_501_for_ticket_sales_type()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedCrystalProduct(factory, viewerId, crystals: 1000);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":3}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
Assert.That((int)response.StatusCode, Is.EqualTo(501));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Rupy_buy_debits_and_grants()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedRupyProduct(factory, viewerId, rupees: 200);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":10,"sales_type":2}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.Cards).FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.Currency.Rupees, Is.EqualTo(100UL));
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var entries = doc.RootElement.GetProperty("reward_list");
|
||||
bool foundRupy = false;
|
||||
for (int i = 0; i < entries.GetArrayLength(); i++)
|
||||
{
|
||||
if (entries[i].GetProperty("reward_type").GetInt32() == 9)
|
||||
{
|
||||
Assert.That(entries[i].GetProperty("reward_num").GetInt32(), Is.EqualTo(100));
|
||||
foundRupy = true;
|
||||
}
|
||||
}
|
||||
Assert.That(foundRupy, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Free_buy_grants_cards_without_currency_entry()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedFreeProduct(factory, viewerId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":20,"sales_type":0}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var entries = doc.RootElement.GetProperty("reward_list");
|
||||
for (int i = 0; i < entries.GetArrayLength(); i++)
|
||||
{
|
||||
int t = entries[i].GetProperty("reward_type").GetInt32();
|
||||
Assert.That(t, Is.Not.EqualTo(2), "free buy must not emit Crystal entry");
|
||||
Assert.That(t, Is.Not.EqualTo(9), "free buy must not emit Rupy entry");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Free_buy_against_nonfree_product_returns_BadRequest()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedCrystalProduct(factory, viewerId, crystals: 1000);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":0}""";
|
||||
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Buy_emits_newly_unlocked_series_tier_rewards()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = 105, OrderIndex = 18, IsEnabled = true, NameKey = "x", IntroKey = "x",
|
||||
SeriesRewards =
|
||||
{
|
||||
// Tier 1: one card reward, unlocked on the 1st series purchase.
|
||||
new BuildDeckSeriesRewardEntry
|
||||
{
|
||||
TierIndex = 1, ItemIndex = 0, RewardType = 5,
|
||||
RewardDetailId = 10001001L, RewardNumber = 1, MessageId = 51004,
|
||||
},
|
||||
// Tier 2: one card reward, unlocked on the 2nd series purchase.
|
||||
new BuildDeckSeriesRewardEntry
|
||||
{
|
||||
TierIndex = 2, ItemIndex = 0, RewardType = 5,
|
||||
RewardDetailId = 10001002L, RewardNumber = 1, MessageId = 51004,
|
||||
},
|
||||
},
|
||||
Products =
|
||||
{
|
||||
new BuildDeckProductEntry
|
||||
{
|
||||
Id = 501, SeriesId = 105, LeaderId = 1, DeckCode = "pd0501",
|
||||
PurchaseNumMax = 3, IntroPriceCrystal = 0, RegularPriceCrystal = 0,
|
||||
IntroPriceRupy = 0, RegularPriceRupy = 0, IsEnabled = true,
|
||||
Cards = { new BuildDeckProductCardEntry { CardId = 10001003L, Number = 1, IsSpot = false } },
|
||||
},
|
||||
new BuildDeckProductEntry
|
||||
{
|
||||
Id = 502, SeriesId = 105, LeaderId = 2, DeckCode = "pd0502",
|
||||
PurchaseNumMax = 3, IntroPriceCrystal = 0, RegularPriceCrystal = 0,
|
||||
IntroPriceRupy = 0, RegularPriceRupy = 0, IsEnabled = true,
|
||||
Cards = { new BuildDeckProductCardEntry { CardId = 10001003L, Number = 1, IsSpot = false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
// 1st series purchase (product 501) should emit tier 1 only.
|
||||
var r1 = await client.PostAsync("/build_deck/buy",
|
||||
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":501,"sales_type":0}"""));
|
||||
Assert.That(r1.StatusCode, Is.EqualTo(HttpStatusCode.OK), await r1.Content.ReadAsStringAsync());
|
||||
|
||||
using (var doc = JsonDocument.Parse(await r1.Content.ReadAsStringAsync()))
|
||||
{
|
||||
var tiers = doc.RootElement.GetProperty("series_rewards");
|
||||
Assert.That(tiers.GetArrayLength(), Is.EqualTo(1), "only tier 1 newly crossed");
|
||||
Assert.That(tiers[0].GetProperty("reward_detail_id").GetInt64(), Is.EqualTo(10001001L));
|
||||
}
|
||||
|
||||
// 2nd series purchase (product 502) should emit tier 2 only.
|
||||
var r2 = await client.PostAsync("/build_deck/buy",
|
||||
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":502,"sales_type":0}"""));
|
||||
using (var doc = JsonDocument.Parse(await r2.Content.ReadAsStringAsync()))
|
||||
{
|
||||
var tiers = doc.RootElement.GetProperty("series_rewards");
|
||||
Assert.That(tiers.GetArrayLength(), Is.EqualTo(1));
|
||||
Assert.That(tiers[0].GetProperty("reward_detail_id").GetInt64(), Is.EqualTo(10001002L));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
public class BuildDeckControllerGetPurchaseCountTests
|
||||
{
|
||||
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
private static async Task SeedEnabledProduct(SVSimTestFactory f, int productId, int max)
|
||||
{
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = 101, OrderIndex = 22, IsEnabled = true, NameKey = "BDSSN_test", IntroKey = "BDSI_test",
|
||||
});
|
||||
db.BuildDeckProducts.Add(new BuildDeckProductEntry
|
||||
{
|
||||
Id = productId, SeriesId = 101, IsEnabled = true,
|
||||
PurchaseNumMax = max, IntroPriceCrystal = 500,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Returns_zero_current_and_max_for_unbought_product()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedEnabledProduct(factory, productId: 201, max: 3);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":201}""";
|
||||
var response = await client.PostAsync("/build_deck/get_purchase_count", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("purchase_num_current").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(doc.RootElement.GetProperty("purchase_num_max").GetInt32(), Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Returns_NotFound_for_unknown_product()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":99999}""";
|
||||
var response = await client.PostAsync("/build_deck/get_purchase_count", JsonBody(json));
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
144
SVSim.UnitTests/Controllers/BuildDeckControllerInfoTests.cs
Normal file
144
SVSim.UnitTests/Controllers/BuildDeckControllerInfoTests.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
public class BuildDeckControllerInfoTests
|
||||
{
|
||||
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
private static async Task SeedTwoSeries(SVSimTestFactory f)
|
||||
{
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
var seriesA = new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = 101, OrderIndex = 22, IsEnabled = true, IsNew = false,
|
||||
NameKey = "BDSSN_A", IntroKey = "BDSI_A",
|
||||
Products =
|
||||
{
|
||||
new BuildDeckProductEntry
|
||||
{
|
||||
Id = 1, SeriesId = 101, LeaderId = 1, DeckCode = "pd0101",
|
||||
ProductNameKey = "BDPN_A_elf", FeaturedCardId = 100,
|
||||
PurchaseNumMax = 3, IntroPriceCrystal = 500, RegularPriceCrystal = 750,
|
||||
IsEnabled = true,
|
||||
},
|
||||
},
|
||||
};
|
||||
var seriesB = new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = 107, OrderIndex = 15, IsEnabled = true, IsNew = false,
|
||||
NameKey = "BDSSN_B", IntroKey = "BDSI_B",
|
||||
Products =
|
||||
{
|
||||
new BuildDeckProductEntry
|
||||
{
|
||||
Id = 701, SeriesId = 107, LeaderId = 1, DeckCode = "pd0107",
|
||||
ProductNameKey = "BDPN_B_elf", FeaturedCardId = 200,
|
||||
PurchaseNumMax = 1, IntroPriceCrystal = 1200,
|
||||
IsEnabled = true,
|
||||
},
|
||||
},
|
||||
};
|
||||
var disabled = new BuildDeckSeriesEntry
|
||||
{
|
||||
Id = 10100, OrderIndex = 999, IsEnabled = false, NameKey = "BDSSN_TEMP", IntroKey = "BDSI_TEMP",
|
||||
};
|
||||
db.BuildDeckSeries.AddRange(seriesA, seriesB, disabled);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Returns_only_enabled_series_sorted_by_order_index_desc()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedTwoSeries(factory);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","add_series_id":0}""";
|
||||
var response = await client.PostAsync("/build_deck/info", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var list = doc.RootElement; // controller returns a bare array — `data` IS the series list
|
||||
Assert.That(list.GetArrayLength(), Is.EqualTo(2));
|
||||
Assert.That(list[0].GetProperty("series_id").GetInt32(), Is.EqualTo(101), "OrderIndex 22 sorts first");
|
||||
Assert.That(list[1].GetProperty("series_id").GetInt32(), Is.EqualTo(107));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Filters_to_single_series_when_add_series_id_nonzero()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedTwoSeries(factory);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","add_series_id":107}""";
|
||||
var response = await client.PostAsync("/build_deck/info", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var list = doc.RootElement;
|
||||
Assert.That(list.GetArrayLength(), Is.EqualTo(1));
|
||||
Assert.That(list[0].GetProperty("series_id").GetInt32(), Is.EqualTo(107));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Emits_intro_price_and_is_first_price_true_for_unbought_max3_product()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedTwoSeries(factory);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","add_series_id":101}""";
|
||||
var response = await client.PostAsync("/build_deck/info", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var product = doc.RootElement[0].GetProperty("products")[0];
|
||||
Assert.That(product.GetProperty("is_first_price").GetBoolean(), Is.True);
|
||||
Assert.That(product.GetProperty("price_crystal").GetInt32(), Is.EqualTo(500));
|
||||
Assert.That(product.GetProperty("purchase_num_current").GetInt32(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Emits_regular_price_after_first_purchase_recorded()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedTwoSeries(factory);
|
||||
|
||||
// Record a purchase directly to simulate post-buy state.
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.BuildDeckPurchases).FirstAsync(x => x.Id == viewerId);
|
||||
v.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = 1, PurchaseCount = 1 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","add_series_id":101}""";
|
||||
var response = await client.PostAsync("/build_deck/info", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var product = doc.RootElement[0].GetProperty("products")[0];
|
||||
Assert.That(product.GetProperty("is_first_price").GetBoolean(), Is.False);
|
||||
Assert.That(product.GetProperty("price_crystal").GetInt32(), Is.EqualTo(750));
|
||||
Assert.That(product.GetProperty("purchase_num_current").GetInt32(), Is.EqualTo(1));
|
||||
}
|
||||
}
|
||||
126
SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs
Normal file
126
SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
public class LeaderSkinControllerTests
|
||||
{
|
||||
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
/// <summary>Adds a class-4 leader skin (id 104, "Forte") to the catalog and to the viewer's owned list.</summary>
|
||||
private static async Task SeedOwnedClass4Skin(SVSimTestFactory f, long viewerId, int skinId = 104)
|
||||
{
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var skin = await db.LeaderSkins.FindAsync(skinId);
|
||||
if (skin is null)
|
||||
{
|
||||
skin = new LeaderSkinEntry { Id = skinId, Name = "Forte", ClassId = 4 };
|
||||
db.LeaderSkins.Add(skin);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var viewer = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||
if (viewer.LeaderSkins.All(s => s.Id != skinId)) viewer.LeaderSkins.Add(skin);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Set_updates_viewer_class_leader_skin()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOwnedClass4Skin(factory, viewerId, skinId: 104);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":4,"leader_skin_id":104,"is_random_leader_skin":false,"leader_skin_id_list":[]}""";
|
||||
var response = await client.PostAsync("/leader_skin/set", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers
|
||||
.Include(v => v.Classes).ThenInclude(c => c.LeaderSkin)
|
||||
.Include(v => v.Classes).ThenInclude(c => c.Class)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
var class4 = viewer.Classes.Single(c => c.Class.Id == 4);
|
||||
Assert.That(class4.LeaderSkin.Id, Is.EqualTo(104));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Set_is_reflected_in_subsequent_deck_info_response()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedOwnedClass4Skin(factory, viewerId, skinId: 104);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
// Switch class 4 leader to skin 104
|
||||
await client.PostAsync("/leader_skin/set",
|
||||
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":4,"leader_skin_id":104,"is_random_leader_skin":false,"leader_skin_id_list":[]}"""));
|
||||
|
||||
// /deck/info should now report class 4 with leader_skin_id=104
|
||||
var resp = await client.PostAsync("/deck/info",
|
||||
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":0}"""));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var settings = doc.RootElement.GetProperty("user_leader_skin_setting_list");
|
||||
Assert.That(settings.TryGetProperty("4", out var class4Setting), Is.True, "class 4 entry must be present");
|
||||
Assert.That(class4Setting.GetProperty("leader_skin_id").GetInt32(), Is.EqualTo(104));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Set_rejects_skin_viewer_doesnt_own()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// Skin 104 (Forte) is in the seeded leaderskins.csv catalog but a fresh viewer only owns
|
||||
// the 8 class default skins — confirm 104 isn't in viewer.LeaderSkins, then call /set.
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var skin = await db.LeaderSkins.FindAsync(104);
|
||||
Assert.That(skin, Is.Not.Null, "leaderskins.csv fixture should include skin 104");
|
||||
var viewer = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.LeaderSkins.Any(s => s.Id == 104), Is.False, "fresh viewer must not own skin 104");
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":4,"leader_skin_id":104,"is_random_leader_skin":false,"leader_skin_id_list":[]}""";
|
||||
var resp = await client.PostAsync("/leader_skin/set", JsonBody(json));
|
||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Set_rejects_skin_for_wrong_class()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// Skin 104 is class 4 — try to assign it to class 6
|
||||
await SeedOwnedClass4Skin(factory, viewerId, skinId: 104);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":6,"leader_skin_id":104,"is_random_leader_skin":false,"leader_skin_id_list":[]}""";
|
||||
var resp = await client.PostAsync("/leader_skin/set", JsonBody(json));
|
||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Set_returns_501_for_random_leader_skin_mode()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":4,"leader_skin_id":0,"is_random_leader_skin":true,"leader_skin_id_list":[4,104]}""";
|
||||
var resp = await client.PostAsync("/leader_skin/set", JsonBody(json));
|
||||
Assert.That((int)resp.StatusCode, Is.EqualTo(501));
|
||||
}
|
||||
}
|
||||
110
SVSim.UnitTests/Importers/BuildDeckImporterTests.cs
Normal file
110
SVSim.UnitTests/Importers/BuildDeckImporterTests.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Bootstrap.Importers;
|
||||
using SVSim.Database;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Importers;
|
||||
|
||||
public class BuildDeckImporterTests
|
||||
{
|
||||
private static string DataDir => Path.Combine(AppContext.BaseDirectory, "Data");
|
||||
|
||||
[Test]
|
||||
public async Task ImportsAll22Series_with_22_disabled_until_catalog_enables()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new BuildDeckImporter().ImportSeriesAsync(db, DataDir);
|
||||
|
||||
var series = await db.BuildDeckSeries.OrderBy(s => s.Id).ToListAsync();
|
||||
Assert.That(series.Count, Is.EqualTo(22));
|
||||
Assert.That(series.All(s => !s.IsEnabled), Is.True, "all series disabled until catalog importer runs");
|
||||
Assert.That(series.Any(s => s.NameKey.StartsWith("BDSSN_")), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportPackage_creates_stub_products_with_inferred_series_and_full_card_lists()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new BuildDeckImporter().ImportSeriesAsync(db, DataDir);
|
||||
await new BuildDeckImporter().ImportPackageAsync(db, DataDir);
|
||||
|
||||
var products = await db.BuildDeckProducts.Include(p => p.Cards).ToListAsync();
|
||||
Assert.That(products.Count, Is.EqualTo(112), "stubs for all 112 products");
|
||||
Assert.That(products.All(p => !p.IsEnabled), Is.True, "stubs are disabled until catalog enables");
|
||||
Assert.That(products.All(p => p.Cards.Sum(c => c.Number) == 40), Is.True, "every product is a 40-card deck");
|
||||
|
||||
// Spot-check a known mapping: product 1 -> series 101 via the InferSeriesId helper.
|
||||
var p1 = products.Single(p => p.Id == 1);
|
||||
Assert.That(p1.SeriesId, Is.EqualTo(101));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Importer_is_idempotent_on_rerun()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
var importer = new BuildDeckImporter();
|
||||
await importer.ImportSeriesAsync(db, DataDir);
|
||||
await importer.ImportPackageAsync(db, DataDir);
|
||||
await importer.ImportSeriesAsync(db, DataDir);
|
||||
await importer.ImportPackageAsync(db, DataDir);
|
||||
|
||||
Assert.That(await db.BuildDeckSeries.CountAsync(), Is.EqualTo(22));
|
||||
Assert.That(await db.BuildDeckProducts.CountAsync(), Is.EqualTo(112));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportCatalog_enriches_7_captured_series_with_prices_and_tiers()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
var importer = new BuildDeckImporter();
|
||||
await importer.ImportSeriesAsync(db, DataDir);
|
||||
await importer.ImportCatalogAsync(db, Path.Combine(DataDir, "prod-captures"));
|
||||
await importer.ImportPackageAsync(db, DataDir);
|
||||
|
||||
// Series 101 (Set 1) should be enabled and order_id=22 from capture
|
||||
var s101 = await db.BuildDeckSeries
|
||||
.Include(s => s.Products).ThenInclude(p => p.Cards)
|
||||
.Include(s => s.Products).ThenInclude(p => p.Rewards)
|
||||
.Include(s => s.SeriesRewards)
|
||||
.FirstAsync(s => s.Id == 101);
|
||||
Assert.That(s101.IsEnabled, Is.True);
|
||||
Assert.That(s101.OrderIndex, Is.EqualTo(22));
|
||||
Assert.That(s101.Products.Count, Is.EqualTo(7), "Set 1 has 7 products (no Nemesis)");
|
||||
|
||||
// Set 1 products: max=3, intro=500 backfilled from siblings, regular=750 backfilled from siblings
|
||||
var product1 = s101.Products.Single(p => p.Id == 1);
|
||||
Assert.That(product1.IsEnabled, Is.True);
|
||||
Assert.That(product1.PurchaseNumMax, Is.EqualTo(3));
|
||||
Assert.That(product1.IntroPriceCrystal, Is.EqualTo(500));
|
||||
Assert.That(product1.RegularPriceCrystal, Is.EqualTo(750));
|
||||
|
||||
// Series 107 (Set 7) products: max=1, intro=1200, regular=null
|
||||
var s107 = await db.BuildDeckSeries
|
||||
.Include(s => s.Products)
|
||||
.FirstAsync(s => s.Id == 107);
|
||||
Assert.That(s107.Products.All(p => p.PurchaseNumMax == 1), Is.True);
|
||||
Assert.That(s107.Products.All(p => p.IntroPriceCrystal == 1200), Is.True);
|
||||
Assert.That(s107.Products.All(p => p.RegularPriceCrystal == null), Is.True);
|
||||
|
||||
// Series 105 should have populated series-reward tiers (from the capture)
|
||||
var s105 = await db.BuildDeckSeries.Include(s => s.SeriesRewards).FirstAsync(s => s.Id == 105);
|
||||
Assert.That(s105.SeriesRewards.Count, Is.GreaterThan(0), "Set 5 has series-reward tiers");
|
||||
|
||||
// Series 10100 (Temporary Deck) should still be disabled — not in capture
|
||||
var sTemp = await db.BuildDeckSeries.FirstAsync(s => s.Id == 10100);
|
||||
Assert.That(sTemp.IsEnabled, Is.False);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,17 @@
|
||||
<Content Include="Story\Fixtures\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- BuildDeckImporter reads CSVs from a build-deck/ subdirectory; the top-level Data\*.csv
|
||||
glob only covers the root. Link both files explicitly so they land in the same relative
|
||||
path the importer uses at runtime. -->
|
||||
<Content Include="..\SVSim.Bootstrap\Data\build-deck\build_deck_package_master.csv">
|
||||
<Link>Data\build-deck\build_deck_package_master.csv</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="..\SVSim.Bootstrap\Data\build-deck\build_deck_series_master.csv">
|
||||
<Link>Data\build-deck\build_deck_series_master.csv</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user