Prebuilt deck purchasing and fixes

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -0,0 +1,366 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Models;
using static SVSim.Bootstrap.Importers.ImporterBase;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Loads the prebuilt-deck catalog from a mix of client-master CSVs and one prod-capture JSON.
/// Three methods run in dependency order (see Bootstrap/Program.cs):
/// 1. ImportSeriesAsync — build_deck_series_master.csv → 22 series rows (all IsEnabled=false initially)
/// 2. ImportCatalogAsync — prod-captures/build_deck-info-*.json → enriches 7 series + 53 products (Task 15)
/// 3. ImportPackageAsync — build_deck_package_master.csv → card lists for all 112 products,
/// creates disabled stubs for products not seeded by the catalog importer
/// Idempotent — re-runnable on the same files.
/// </summary>
public class BuildDeckImporter
{
private const string BuildDeckSubdir = "build-deck";
public async Task<int> ImportSeriesAsync(SVSimDbContext db, string dataDir)
{
string csvPath = Path.Combine(dataDir, BuildDeckSubdir, "build_deck_series_master.csv");
if (!File.Exists(csvPath))
{
Console.Error.WriteLine($"[BuildDeckImporter] series CSV missing: {csvPath}");
return 0;
}
var rows = ReadCsv(csvPath).Skip(1).ToList(); // skip header
int created = 0, updated = 0;
var existing = await db.BuildDeckSeries.ToDictionaryAsync(s => s.Id);
foreach (var cols in rows)
{
if (cols.Length < 5) continue;
if (!int.TryParse(cols[0], out int id)) continue;
if (existing.TryGetValue(id, out var row))
{
// Update CSV-derived fields; do not flip IsEnabled or OrderIndex (catalog importer owns those)
bool changed = false;
if (row.NameKey != cols[1]) { row.NameKey = cols[1]; changed = true; }
if (row.IntroKey != cols[2]) { row.IntroKey = cols[2]; changed = true; }
if (row.TitlePath != cols[3]) { row.TitlePath = cols[3]; changed = true; }
if (row.DrumrollPath != cols[4]) { row.DrumrollPath = cols[4]; changed = true; }
if (changed) updated++;
}
else
{
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
{
Id = id,
NameKey = cols[1],
IntroKey = cols[2],
TitlePath = cols[3],
DrumrollPath = cols[4],
OrderIndex = 0,
IsNew = false,
IsEnabled = false,
});
created++;
}
}
await db.SaveChangesAsync();
Console.WriteLine($"[BuildDeckImporter] Series: created={created}, updated={updated}");
return created + updated;
}
public async Task<int> ImportPackageAsync(SVSimDbContext db, string dataDir)
{
string csvPath = Path.Combine(dataDir, BuildDeckSubdir, "build_deck_package_master.csv");
if (!File.Exists(csvPath))
{
Console.Error.WriteLine($"[BuildDeckImporter] package CSV missing: {csvPath}");
return 0;
}
var rows = ReadCsv(csvPath).Skip(1).ToList(); // header: product_id,card_id,number,is_spot
var byProduct = rows
.Where(c => c.Length >= 4)
.GroupBy(c => int.Parse(c[0]))
.ToDictionary(g => g.Key, g => g.Select(c => new BuildDeckProductCardEntry
{
CardId = long.Parse(c[1]),
Number = int.Parse(c[2]),
IsSpot = int.Parse(c[3]) != 0,
}).ToList());
// Load existing products (we may have stubs from a prior run or rows created by catalog importer)
var existing = await db.BuildDeckProducts.Include(p => p.Cards).ToDictionaryAsync(p => p.Id);
int created = 0, updated = 0;
foreach (var (productId, cardEntries) in byProduct)
{
if (existing.TryGetValue(productId, out var product))
{
// Replace card list wholesale — CSV is authoritative.
product.Cards.Clear();
foreach (var c in cardEntries) product.Cards.Add(c);
updated++;
}
else
{
int? seriesId = InferSeriesId(productId);
if (seriesId is null)
{
Console.Error.WriteLine($"[BuildDeckImporter] product {productId} has no inferable series; skipping");
continue;
}
db.BuildDeckProducts.Add(new BuildDeckProductEntry
{
Id = productId,
SeriesId = seriesId.Value,
LeaderId = 0,
DeckCode = string.Empty,
ProductNameKey = string.Empty,
FeaturedCardId = 0,
PurchaseNumMax = 1,
IntroPriceCrystal = null,
RegularPriceCrystal = null,
IntroPriceRupy = null,
RegularPriceRupy = null,
IsEnabled = false,
Cards = cardEntries,
});
created++;
}
}
await db.SaveChangesAsync();
Console.WriteLine($"[BuildDeckImporter] Package: created={created}, updated={updated}");
return created + updated;
}
public async Task<int> ImportCatalogAsync(SVSimDbContext db, string capturesDir)
{
var data = LoadCapture(capturesDir, "build_deck-info");
if (data is null) return 0;
int touchedSeries = 0, touchedProducts = 0;
// Load existing rows for fast lookup
var existingSeries = await db.BuildDeckSeries
.Include(s => s.SeriesRewards)
.ToDictionaryAsync(s => s.Id);
var existingProducts = await db.BuildDeckProducts
.Include(p => p.Rewards)
.ToDictionaryAsync(p => p.Id);
// The captured data root is an object keyed by order_id string ("15"…"21"); iterate values.
foreach (var seriesNode in data.Value.EnumerateObject())
{
var s = seriesNode.Value;
int seriesId = GetInt(s, "series_id");
int orderId = GetInt(s, "order_id");
bool isNew = GetBool(s, "is_new");
if (!existingSeries.TryGetValue(seriesId, out var seriesRow))
{
// Catalog runs before package importer in production, so series rows from the series
// CSV should already exist. If not (e.g. the capture has a series the CSV doesn't),
// create a bare row so the FK from products holds.
seriesRow = new BuildDeckSeriesEntry
{
Id = seriesId, NameKey = string.Empty, IntroKey = string.Empty,
TitlePath = string.Empty, DrumrollPath = string.Empty,
};
db.BuildDeckSeries.Add(seriesRow);
existingSeries[seriesId] = seriesRow;
}
seriesRow.OrderIndex = orderId;
seriesRow.IsNew = isNew;
seriesRow.IsEnabled = true;
// Series rewards: replace wholesale (capture is authoritative for enabled series)
seriesRow.SeriesRewards.Clear();
if (s.TryGetProperty("series_rewards", out var seriesRewards) &&
seriesRewards.ValueKind == JsonValueKind.Object)
{
foreach (var tier in seriesRewards.EnumerateObject())
{
if (!int.TryParse(tier.Name, out int tierIndex)) continue;
if (!tier.Value.TryGetProperty("reward_list", out var rewardList) ||
rewardList.ValueKind != JsonValueKind.Array) continue;
int itemIndex = 0;
foreach (var r in rewardList.EnumerateArray())
{
seriesRow.SeriesRewards.Add(new BuildDeckSeriesRewardEntry
{
TierIndex = tierIndex,
ItemIndex = itemIndex++,
RewardType = GetInt(r, "reward_type"),
RewardDetailId = GetLong(r, "reward_detail_id"),
RewardNumber = GetInt(r, "reward_number"),
MessageId = GetInt(r, "message_id"),
});
}
}
}
touchedSeries++;
// Products
if (!s.TryGetProperty("products", out var products) || products.ValueKind != JsonValueKind.Array)
continue;
// First pass: parse each captured product, track intro/regular tiers per product.
var capturedThisSeries = new List<BuildDeckProductEntry>();
foreach (var p in products.EnumerateArray())
{
int productId = GetInt(p, "product_id");
if (!existingProducts.TryGetValue(productId, out var productRow))
{
productRow = new BuildDeckProductEntry { Id = productId, SeriesId = seriesId };
db.BuildDeckProducts.Add(productRow);
existingProducts[productId] = productRow;
}
productRow.SeriesId = seriesId;
productRow.LeaderId = GetInt(p, "leader_id");
productRow.DeckCode = GetString(p, "deck_code");
productRow.ProductNameKey = GetString(p, "product_name");
productRow.FeaturedCardId = GetLong(p, "featured_card_id");
productRow.PurchaseNumMax = GetInt(p, "purchase_num_max");
productRow.IsEnabled = true;
bool isFirstPrice = GetBool(p, "is_first_price");
// Tier-aware price ingestion: each captured row has ONE price tier (intro OR regular).
int? priceCrystal = p.TryGetProperty("price_crystal", out var pc) && pc.ValueKind != JsonValueKind.Null
? (int?)GetInt(p, "price_crystal") : null;
int? priceRupy = p.TryGetProperty("price_rupy", out var pr) && pr.ValueKind != JsonValueKind.Null
? (int?)GetInt(p, "price_rupy") : null;
if (priceCrystal is not null)
{
if (isFirstPrice) productRow.IntroPriceCrystal = priceCrystal;
else productRow.RegularPriceCrystal = priceCrystal;
}
if (priceRupy is not null)
{
if (isFirstPrice) productRow.IntroPriceRupy = priceRupy;
else productRow.RegularPriceRupy = priceRupy;
}
// Product rewards: replace wholesale
productRow.Rewards.Clear();
if (p.TryGetProperty("rewards", out var rewards) && rewards.ValueKind == JsonValueKind.Object)
{
foreach (var r in rewards.EnumerateObject())
{
if (!int.TryParse(r.Name, out int idx)) continue;
productRow.Rewards.Add(new BuildDeckProductRewardEntry
{
RewardIndex = idx,
RewardType = GetInt(r.Value, "reward_type"),
RewardDetailId = GetLong(r.Value, "reward_detail_id"),
RewardNumber = GetInt(r.Value, "reward_number"),
MessageId = GetInt(r.Value, "message_id"),
});
}
}
capturedThisSeries.Add(productRow);
touchedProducts++;
}
// Second pass: backfill missing tier per-series when sibling products share a unique value.
BackfillSeriesTier(capturedThisSeries);
}
await db.SaveChangesAsync();
Console.WriteLine($"[BuildDeckImporter] Catalog: series={touchedSeries}, products={touchedProducts}");
return touchedSeries + touchedProducts;
}
private static void BackfillSeriesTier(IReadOnlyList<BuildDeckProductEntry> productsInSeries)
{
// For each (Currency, Tier) pair, if all populated values across siblings are the same,
// propagate that value to products that are missing the corresponding tier.
BackfillIntroCrystal(productsInSeries);
BackfillRegularCrystal(productsInSeries);
BackfillIntroRupy(productsInSeries);
BackfillRegularRupy(productsInSeries);
}
private static void BackfillIntroCrystal(IReadOnlyList<BuildDeckProductEntry> products)
{
var distinct = products.Where(p => p.IntroPriceCrystal.HasValue).Select(p => p.IntroPriceCrystal!.Value).Distinct().ToList();
if (distinct.Count != 1) return;
int value = distinct[0];
foreach (var p in products)
{
if (p.IntroPriceCrystal is null) p.IntroPriceCrystal = value;
}
}
private static void BackfillRegularCrystal(IReadOnlyList<BuildDeckProductEntry> products)
{
var distinct = products.Where(p => p.RegularPriceCrystal.HasValue).Select(p => p.RegularPriceCrystal!.Value).Distinct().ToList();
if (distinct.Count != 1) return;
int value = distinct[0];
foreach (var p in products)
{
// For PurchaseNumMax == 1 products, never backfill the Regular tier — they have no second buy.
if (p.PurchaseNumMax <= 1) continue;
if (p.RegularPriceCrystal is null) p.RegularPriceCrystal = value;
}
}
private static void BackfillIntroRupy(IReadOnlyList<BuildDeckProductEntry> products)
{
var distinct = products.Where(p => p.IntroPriceRupy.HasValue).Select(p => p.IntroPriceRupy!.Value).Distinct().ToList();
if (distinct.Count != 1) return;
int value = distinct[0];
foreach (var p in products)
{
if (p.IntroPriceRupy is null) p.IntroPriceRupy = value;
}
}
private static void BackfillRegularRupy(IReadOnlyList<BuildDeckProductEntry> products)
{
var distinct = products.Where(p => p.RegularPriceRupy.HasValue).Select(p => p.RegularPriceRupy!.Value).Distinct().ToList();
if (distinct.Count != 1) return;
int value = distinct[0];
foreach (var p in products)
{
if (p.PurchaseNumMax <= 1) continue;
if (p.RegularPriceRupy is null) p.RegularPriceRupy = value;
}
}
/// <summary>
/// Maps a product_id to its series_id using the numeric pattern derived from the /info capture
/// and CSV inspection.
/// Sets 17: products 17, 201299, 301399, 401499, 501599, 601699, 701799 → series 101107
/// Temporary Deck: products 1000110099 → series 10100
/// Trial series: products NNxx where NN in [119,…,132] → series NN00 (divide-by-100 * 100)
/// </summary>
internal static int? InferSeriesId(int productId) => productId switch
{
>= 1 and <= 7 => 101,
>= 201 and <= 299 => 102,
>= 301 and <= 399 => 103,
>= 401 and <= 499 => 104,
>= 501 and <= 599 => 105,
>= 601 and <= 699 => 106,
>= 701 and <= 799 => 107,
>= 10001 and <= 10099 => 10100,
>= 11901 and <= 13299 => (productId / 100) * 100,
_ => null,
};
private static IEnumerable<string[]> ReadCsv(string path)
{
foreach (var raw in File.ReadAllLines(path, System.Text.Encoding.UTF8))
{
// Strip UTF-8 BOM on the first line if present
var line = raw.TrimStart('');
if (string.IsNullOrWhiteSpace(line)) continue;
yield return line.Split(',');
}
}
}

View File

@@ -76,6 +76,14 @@ public static class Program
if (!opts.SkipGlobals)
{
await new GlobalsImporter().ImportAllAsync(context, opts.CapturesDir);
// BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after
// series CSV (FK on products → series) and before package CSV (so the catalog-side
// enriched rows take precedence over stub creation).
var buildDeck = new BuildDeckImporter();
await buildDeck.ImportSeriesAsync(context, opts.ReferenceDataDir);
await buildDeck.ImportCatalogAsync(context, opts.CapturesDir);
await buildDeck.ImportPackageAsync(context, opts.ReferenceDataDir);
}
else
{

View File

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

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

View File

@@ -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");

View 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; }
}

View 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; }
}

View 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; }
}

View 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();
}

View 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; }
}

View File

@@ -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

View 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; }
}

View 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;
}
}

View File

@@ -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);
}

View File

@@ -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 });

View File

@@ -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

View 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,
};
}
}

View File

@@ -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
};

View 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(),
};
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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>();
}

View File

@@ -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();
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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();
}

View File

@@ -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.

View 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));
}
}
}

View File

@@ -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));
}
}

View 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));
}
}

View 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));
}
}

View 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);
}
}

View File

@@ -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>