feat(leader-skin): shop catalog + 5 endpoints (/products, /buy, /buy_set, /buy_set_item, /ids)
Schema: LeaderSkinShopSeries -> Products (owned rewards) + owned SetCompletionRewards on the series; ViewerLeaderSkinSetClaim composite PK (ViewerId, SeriesId) backs the /buy_set_item idempotent-claim check. Importer mirrors SleeveShopImporter: idempotent find-or-create, owned collections rewritten wholesale on rerun. 16 series, 104 products. Controller (extends existing /set with 5 new endpoints): - /products: dict-keyed-by-series_id-string wire shape. is_completed per-viewer, rewards.status from ViewerLeaderSkinSetClaim (0=no set sale, 1=available, 2=claimed) matching client RewardStatus enum. - /buy: single skin, sales_type 1/2 dispatch, 3=>501. - /buy_set: whole series at SetPrice; requires set_sales_status != 0; grants every product's rewards (RewardGrantService idempotent on already-owned cosmetics, so partial-set buys don't double-add). - /buy_set_item: requires viewer owns every skin in series; idempotent on re-claim (returns 200 + empty reward_list, not 400) so client retries don't error. - /ids: flat owned-skin-id list for badge refresh. 496 tests pass (was 486; +10 leader-skin-shop tests). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
4053
SVSim.Bootstrap/Data/seeds/leader-skin-shop.json
Normal file
4053
SVSim.Bootstrap/Data/seeds/leader-skin-shop.json
Normal file
File diff suppressed because it is too large
Load Diff
115
SVSim.Bootstrap/Importers/LeaderSkinShopImporter.cs
Normal file
115
SVSim.Bootstrap/Importers/LeaderSkinShopImporter.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Bootstrap.Models.Seed;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
|
namespace SVSim.Bootstrap.Importers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Idempotent upsert of the leader-skin-shop catalog from <c>seeds/leader-skin-shop.json</c>.
|
||||||
|
/// Mirror of <see cref="SleeveShopImporter"/>. Source is the wire
|
||||||
|
/// <c>/leader_skin/products</c> response, extracted via
|
||||||
|
/// <c>data_dumps/extract/extract-leader-skin-shop.py</c>. Rows missing from the seed are LEFT INTACT.
|
||||||
|
/// </summary>
|
||||||
|
public class LeaderSkinShopImporter
|
||||||
|
{
|
||||||
|
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(seedDir, "leader-skin-shop.json");
|
||||||
|
var seed = SeedLoader.LoadList<LeaderSkinShopSeriesSeed>(path);
|
||||||
|
if (seed.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[LeaderSkinShopImporter] No seed rows; skipping.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingSeries = await context.LeaderSkinShopSeries
|
||||||
|
.Include(s => s.SetCompletionRewards)
|
||||||
|
.Include(s => s.Products).ThenInclude(p => p.Rewards)
|
||||||
|
.ToDictionaryAsync(s => s.Id);
|
||||||
|
|
||||||
|
int createdSeries = 0, updatedSeries = 0, createdProducts = 0, updatedProducts = 0;
|
||||||
|
|
||||||
|
foreach (var s in seed)
|
||||||
|
{
|
||||||
|
if (s.SeriesId == 0) continue;
|
||||||
|
|
||||||
|
if (!existingSeries.TryGetValue(s.SeriesId, out var series))
|
||||||
|
{
|
||||||
|
series = new LeaderSkinShopSeriesEntry { Id = s.SeriesId };
|
||||||
|
context.LeaderSkinShopSeries.Add(series);
|
||||||
|
existingSeries[s.SeriesId] = series;
|
||||||
|
createdSeries++;
|
||||||
|
}
|
||||||
|
else updatedSeries++;
|
||||||
|
|
||||||
|
series.IsNew = s.IsNew;
|
||||||
|
series.IsEnabled = true;
|
||||||
|
series.SetSalesStatus = s.SetSalesStatus;
|
||||||
|
series.SetPriceCrystal = s.SetPriceCrystal;
|
||||||
|
series.SetPriceRupy = s.SetPriceRupy;
|
||||||
|
series.SetPriceTicket = s.SetPriceTicket;
|
||||||
|
series.SetPriceTicketId = s.SetPriceTicketId;
|
||||||
|
// SetCompletionRewardStatus stays at the catalog default 0 — per-viewer claim state
|
||||||
|
// is computed at request time from ViewerLeaderSkinSetClaim, not from this column.
|
||||||
|
series.SetCompletionRewardStatus = 0;
|
||||||
|
|
||||||
|
// Replace owned collections wholesale on rerun.
|
||||||
|
series.SetCompletionRewards.Clear();
|
||||||
|
foreach (var r in s.SetCompletionRewards.OrderBy(r => r.OrderIndex))
|
||||||
|
{
|
||||||
|
series.SetCompletionRewards.Add(new LeaderSkinShopSeriesRewardEntry
|
||||||
|
{
|
||||||
|
OrderIndex = r.OrderIndex,
|
||||||
|
RewardType = r.RewardType,
|
||||||
|
RewardDetailId = r.RewardDetailId,
|
||||||
|
RewardNumber = r.RewardNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingProducts = series.Products.ToDictionary(p => p.Id);
|
||||||
|
foreach (var p in s.Products)
|
||||||
|
{
|
||||||
|
if (p.ProductId == 0) continue;
|
||||||
|
|
||||||
|
if (!existingProducts.TryGetValue(p.ProductId, out var product))
|
||||||
|
{
|
||||||
|
product = new LeaderSkinShopProductEntry { Id = p.ProductId };
|
||||||
|
series.Products.Add(product);
|
||||||
|
createdProducts++;
|
||||||
|
}
|
||||||
|
else updatedProducts++;
|
||||||
|
|
||||||
|
product.SeriesId = s.SeriesId;
|
||||||
|
product.LeaderSkinId = p.LeaderSkinId;
|
||||||
|
product.ProductNameKey = p.ProductNameKey;
|
||||||
|
product.IntroductionKey = p.IntroductionKey;
|
||||||
|
product.CvNameKey = p.CvNameKey;
|
||||||
|
product.SinglePriceCrystal = p.SinglePriceCrystal;
|
||||||
|
product.SinglePriceRupy = p.SinglePriceRupy;
|
||||||
|
product.SinglePriceTicket = p.SinglePriceTicket;
|
||||||
|
product.TicketNumber = p.TicketNumber;
|
||||||
|
product.TicketItemId = p.TicketItemId;
|
||||||
|
product.IsEnabled = true;
|
||||||
|
|
||||||
|
product.Rewards.Clear();
|
||||||
|
foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex))
|
||||||
|
{
|
||||||
|
product.Rewards.Add(new LeaderSkinShopProductRewardEntry
|
||||||
|
{
|
||||||
|
OrderIndex = r.OrderIndex,
|
||||||
|
RewardType = r.RewardType,
|
||||||
|
RewardDetailId = r.RewardDetailId,
|
||||||
|
RewardNumber = r.RewardNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[LeaderSkinShopImporter] series +{createdSeries}/~{updatedSeries}, " +
|
||||||
|
$"products +{createdProducts}/~{updatedProducts}");
|
||||||
|
return createdSeries + updatedSeries;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
SVSim.Bootstrap/Models/Seed/LeaderSkinShopSeed.cs
Normal file
39
SVSim.Bootstrap/Models/Seed/LeaderSkinShopSeed.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SVSim.Bootstrap.Models.Seed;
|
||||||
|
|
||||||
|
public sealed class LeaderSkinShopSeriesSeed
|
||||||
|
{
|
||||||
|
[JsonPropertyName("series_id")] public int SeriesId { get; set; }
|
||||||
|
[JsonPropertyName("is_new")] public bool IsNew { get; set; }
|
||||||
|
[JsonPropertyName("set_sales_status")] public int SetSalesStatus { get; set; }
|
||||||
|
[JsonPropertyName("set_price_crystal")] public int? SetPriceCrystal { get; set; }
|
||||||
|
[JsonPropertyName("set_price_rupy")] public int? SetPriceRupy { get; set; }
|
||||||
|
[JsonPropertyName("set_price_ticket")] public int? SetPriceTicket { get; set; }
|
||||||
|
[JsonPropertyName("set_price_ticket_id")] public long? SetPriceTicketId { get; set; }
|
||||||
|
[JsonPropertyName("set_completion_rewards")] public List<LeaderSkinShopRewardSeed> SetCompletionRewards { get; set; } = new();
|
||||||
|
[JsonPropertyName("products")] public List<LeaderSkinShopProductSeed> Products { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LeaderSkinShopProductSeed
|
||||||
|
{
|
||||||
|
[JsonPropertyName("product_id")] public int ProductId { get; set; }
|
||||||
|
[JsonPropertyName("leader_skin_id")] public int LeaderSkinId { get; set; }
|
||||||
|
[JsonPropertyName("product_name_key")] public string ProductNameKey { get; set; } = "";
|
||||||
|
[JsonPropertyName("introduction_key")] public string IntroductionKey { get; set; } = "";
|
||||||
|
[JsonPropertyName("cv_name_key")] public string CvNameKey { get; set; } = "";
|
||||||
|
[JsonPropertyName("single_price_crystal")] public int? SinglePriceCrystal { get; set; }
|
||||||
|
[JsonPropertyName("single_price_rupy")] public int? SinglePriceRupy { get; set; }
|
||||||
|
[JsonPropertyName("single_price_ticket")] public int? SinglePriceTicket { get; set; }
|
||||||
|
[JsonPropertyName("ticket_number")] public int? TicketNumber { get; set; }
|
||||||
|
[JsonPropertyName("ticket_item_id")] public long? TicketItemId { get; set; }
|
||||||
|
[JsonPropertyName("rewards")] public List<LeaderSkinShopRewardSeed> Rewards { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LeaderSkinShopRewardSeed
|
||||||
|
{
|
||||||
|
[JsonPropertyName("order_index")] public int OrderIndex { get; set; }
|
||||||
|
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
|
||||||
|
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
|
||||||
|
[JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ public static class Program
|
|||||||
await new ItemImporter().ImportAsync(context, opts.SeedDir);
|
await new ItemImporter().ImportAsync(context, opts.SeedDir);
|
||||||
await new SleeveShopImporter().ImportAsync(context, opts.SeedDir);
|
await new SleeveShopImporter().ImportAsync(context, opts.SeedDir);
|
||||||
await new ItemPurchaseImporter().ImportAsync(context, opts.SeedDir);
|
await new ItemPurchaseImporter().ImportAsync(context, opts.SeedDir);
|
||||||
|
await new LeaderSkinShopImporter().ImportAsync(context, opts.SeedDir);
|
||||||
var puzzleImporter = new PuzzleImporter();
|
var puzzleImporter = new PuzzleImporter();
|
||||||
await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir);
|
await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir);
|
||||||
await puzzleImporter.ImportPuzzlesAsync(context, opts.SeedDir);
|
await puzzleImporter.ImportPuzzlesAsync(context, opts.SeedDir);
|
||||||
|
|||||||
3627
SVSim.Database/Migrations/20260528024430_AddLeaderSkinShop.Designer.cs
generated
Normal file
3627
SVSim.Database/Migrations/20260528024430_AddLeaderSkinShop.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
155
SVSim.Database/Migrations/20260528024430_AddLeaderSkinShop.cs
Normal file
155
SVSim.Database/Migrations/20260528024430_AddLeaderSkinShop.cs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SVSim.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLeaderSkinShop : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "LeaderSkinShopSeries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
IsNew = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
SetSalesStatus = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SetPriceCrystal = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
SetPriceRupy = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
SetPriceTicket = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
SetPriceTicketId = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
SetCompletionRewardStatus = table.Column<int>(type: "integer", 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_LeaderSkinShopSeries", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ViewerLeaderSkinSetClaims",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
SeriesId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ViewerLeaderSkinSetClaims", x => new { x.ViewerId, x.SeriesId });
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "LeaderSkinShopProducts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SeriesId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
LeaderSkinId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ProductNameKey = table.Column<string>(type: "text", nullable: false),
|
||||||
|
IntroductionKey = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CvNameKey = table.Column<string>(type: "text", nullable: false),
|
||||||
|
SinglePriceCrystal = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
SinglePriceRupy = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
SinglePriceTicket = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
TicketNumber = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
TicketItemId = table.Column<long>(type: "bigint", 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_LeaderSkinShopProducts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_LeaderSkinShopProducts_LeaderSkinShopSeries_SeriesId",
|
||||||
|
column: x => x.SeriesId,
|
||||||
|
principalTable: "LeaderSkinShopSeries",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "LeaderSkinShopSeriesRewardEntry",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
LeaderSkinShopSeriesEntryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
OrderIndex = 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)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_LeaderSkinShopSeriesRewardEntry", x => new { x.LeaderSkinShopSeriesEntryId, x.Id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_LeaderSkinShopSeriesRewardEntry_LeaderSkinShopSeries_Leader~",
|
||||||
|
column: x => x.LeaderSkinShopSeriesEntryId,
|
||||||
|
principalTable: "LeaderSkinShopSeries",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "LeaderSkinShopProductRewardEntry",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
LeaderSkinShopProductEntryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
OrderIndex = 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)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_LeaderSkinShopProductRewardEntry", x => new { x.LeaderSkinShopProductEntryId, x.Id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_LeaderSkinShopProductRewardEntry_LeaderSkinShopProducts_Lea~",
|
||||||
|
column: x => x.LeaderSkinShopProductEntryId,
|
||||||
|
principalTable: "LeaderSkinShopProducts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_LeaderSkinShopProducts_SeriesId",
|
||||||
|
table: "LeaderSkinShopProducts",
|
||||||
|
column: "SeriesId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ViewerLeaderSkinSetClaims_ViewerId",
|
||||||
|
table: "ViewerLeaderSkinSetClaims",
|
||||||
|
column: "ViewerId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "LeaderSkinShopProductRewardEntry");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "LeaderSkinShopSeriesRewardEntry");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ViewerLeaderSkinSetClaims");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "LeaderSkinShopProducts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "LeaderSkinShopSeries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1182,6 +1182,100 @@ namespace SVSim.Database.Migrations
|
|||||||
b.ToTable("LeaderSkins");
|
b.ToTable("LeaderSkins");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopProductEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("CvNameKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateCreated")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DateUpdated")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("IntroductionKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("LeaderSkinId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ProductNameKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SinglePriceCrystal")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SinglePriceRupy")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SinglePriceTicket")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<long?>("TicketItemId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int?>("TicketNumber")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("LeaderSkinShopProducts");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopSeriesEntry", 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<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsNew")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("SetCompletionRewardStatus")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SetPriceCrystal")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SetPriceRupy")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SetPriceTicket")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<long?>("SetPriceTicketId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("SetSalesStatus")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("LeaderSkinShopSeries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.LoadingExclusionCardEntry", b =>
|
modelBuilder.Entity("SVSim.Database.Models.LoadingExclusionCardEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -2300,6 +2394,24 @@ namespace SVSim.Database.Migrations
|
|||||||
b.ToTable("ViewerEventCounters");
|
b.ToTable("ViewerEventCounters");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.ViewerLeaderSkinSetClaim", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("ViewerId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ClaimedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("ViewerId", "SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("ViewerId");
|
||||||
|
|
||||||
|
b.ToTable("ViewerLeaderSkinSetClaims");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.ViewerMission", b =>
|
modelBuilder.Entity("SVSim.Database.Models.ViewerMission", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -2714,6 +2826,86 @@ namespace SVSim.Database.Migrations
|
|||||||
b.Navigation("Class");
|
b.Navigation("Class");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopProductEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SVSim.Database.Models.LeaderSkinShopSeriesEntry", "Series")
|
||||||
|
.WithMany("Products")
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsMany("SVSim.Database.Models.LeaderSkinShopProductRewardEntry", "Rewards", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("LeaderSkinShopProductEntryId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b1.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||||
|
|
||||||
|
b1.Property<int>("OrderIndex")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b1.Property<long>("RewardDetailId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b1.Property<int>("RewardNumber")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b1.Property<int>("RewardType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b1.HasKey("LeaderSkinShopProductEntryId", "Id");
|
||||||
|
|
||||||
|
b1.ToTable("LeaderSkinShopProductRewardEntry");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("LeaderSkinShopProductEntryId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Rewards");
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopSeriesEntry", b =>
|
||||||
|
{
|
||||||
|
b.OwnsMany("SVSim.Database.Models.LeaderSkinShopSeriesRewardEntry", "SetCompletionRewards", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("LeaderSkinShopSeriesEntryId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b1.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||||
|
|
||||||
|
b1.Property<int>("OrderIndex")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b1.Property<long>("RewardDetailId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b1.Property<int>("RewardNumber")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b1.Property<int>("RewardType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b1.HasKey("LeaderSkinShopSeriesEntryId", "Id");
|
||||||
|
|
||||||
|
b1.ToTable("LeaderSkinShopSeriesRewardEntry");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("LeaderSkinShopSeriesEntryId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("SetCompletionRewards");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b =>
|
modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b =>
|
||||||
{
|
{
|
||||||
b.OwnsMany("SVSim.Database.Models.PackBannerEntry", "Banners", b1 =>
|
b.OwnsMany("SVSim.Database.Models.PackBannerEntry", "Banners", b1 =>
|
||||||
@@ -3396,6 +3588,11 @@ namespace SVSim.Database.Migrations
|
|||||||
b.Navigation("LeaderSkins");
|
b.Navigation("LeaderSkins");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopSeriesEntry", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Products");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b =>
|
modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Puzzles");
|
b.Navigation("Puzzles");
|
||||||
|
|||||||
36
SVSim.Database/Models/LeaderSkinShopProductEntry.cs
Normal file
36
SVSim.Database/Models/LeaderSkinShopProductEntry.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using SVSim.Database.Common;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One purchasable leader-skin product. PK = wire product_id (small ints in captures — e.g. 31,
|
||||||
|
/// 165, 166). FK <see cref="SeriesId"/>. <see cref="LeaderSkinId"/> points at the
|
||||||
|
/// <see cref="LeaderSkinEntry"/> the buyer ends up owning.
|
||||||
|
/// </summary>
|
||||||
|
public class LeaderSkinShopProductEntry : BaseEntity<int>
|
||||||
|
{
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
public int LeaderSkinId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>SystemText keys — resolved client-side via Data.Master.GetLeaderSkinProductText.</summary>
|
||||||
|
public string ProductNameKey { get; set; } = string.Empty;
|
||||||
|
public string IntroductionKey { get; set; } = string.Empty;
|
||||||
|
public string CvNameKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-product price for solo buy. Captures consistently show crystal/rupy parity for
|
||||||
|
/// regular skins (500c / 500r single, 400 unit-price when bought as set). Nullable so
|
||||||
|
/// promotions can offer one currency without the other.
|
||||||
|
/// </summary>
|
||||||
|
public int? SinglePriceCrystal { get; set; }
|
||||||
|
public int? SinglePriceRupy { get; set; }
|
||||||
|
public int? SinglePriceTicket { get; set; }
|
||||||
|
public int? TicketNumber { get; set; }
|
||||||
|
public long? TicketItemId { get; set; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
|
||||||
|
public List<LeaderSkinShopProductRewardEntry> Rewards { get; set; } = new();
|
||||||
|
|
||||||
|
public LeaderSkinShopSeriesEntry? Series { get; set; }
|
||||||
|
}
|
||||||
17
SVSim.Database/Models/LeaderSkinShopProductRewardEntry.cs
Normal file
17
SVSim.Database/Models/LeaderSkinShopProductRewardEntry.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One per-buy reward attached to a leader-skin product. Owned by
|
||||||
|
/// <see cref="LeaderSkinShopProductEntry"/>. Captures show each skin product bundles 3 rewards:
|
||||||
|
/// the skin itself (type=10), the matching emblem (type=7), and the matching sleeve (type=6).
|
||||||
|
/// </summary>
|
||||||
|
[Owned]
|
||||||
|
public class LeaderSkinShopProductRewardEntry
|
||||||
|
{
|
||||||
|
public int OrderIndex { get; set; }
|
||||||
|
public int RewardType { get; set; } // Wizard.UserGoods.Type
|
||||||
|
public long RewardDetailId { get; set; }
|
||||||
|
public int RewardNumber { get; set; }
|
||||||
|
}
|
||||||
33
SVSim.Database/Models/LeaderSkinShopSeriesEntry.cs
Normal file
33
SVSim.Database/Models/LeaderSkinShopSeriesEntry.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using SVSim.Database.Common;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One leader-skin-shop series (a themed collection — e.g. "7th Anniversary Skins").
|
||||||
|
/// PK = wire series_id. <see cref="SetSalesStatus"/> controls whether the per-series
|
||||||
|
/// "buy whole set" UI is offered: 0=none (single-skin purchases only), non-zero=set sale active.
|
||||||
|
/// When set-active, the set-price + set-completion-reward fields are populated.
|
||||||
|
/// </summary>
|
||||||
|
public class LeaderSkinShopSeriesEntry : BaseEntity<int>
|
||||||
|
{
|
||||||
|
public bool IsNew { get; set; }
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>SkinSeriesPurchaseInfo.eSetSalesStatus — 0=None.</summary>
|
||||||
|
public int SetSalesStatus { get; set; }
|
||||||
|
|
||||||
|
public int? SetPriceCrystal { get; set; }
|
||||||
|
public int? SetPriceRupy { get; set; }
|
||||||
|
public int? SetPriceTicket { get; set; }
|
||||||
|
public long? SetPriceTicketId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SkinSeriesPurchaseInfo.RewardStatus — 0=none. The per-VIEWER claim state is computed
|
||||||
|
/// at request time from <see cref="ViewerLeaderSkinSetClaim"/>; this column is the catalog
|
||||||
|
/// default surfaced when no viewer is in context (or when set_sales_status==0).
|
||||||
|
/// </summary>
|
||||||
|
public int SetCompletionRewardStatus { get; set; }
|
||||||
|
|
||||||
|
public List<LeaderSkinShopProductEntry> Products { get; set; } = new();
|
||||||
|
public List<LeaderSkinShopSeriesRewardEntry> SetCompletionRewards { get; set; } = new();
|
||||||
|
}
|
||||||
18
SVSim.Database/Models/LeaderSkinShopSeriesRewardEntry.cs
Normal file
18
SVSim.Database/Models/LeaderSkinShopSeriesRewardEntry.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One set-completion bonus item attached to a leader-skin series. Owned by
|
||||||
|
/// <see cref="LeaderSkinShopSeriesEntry"/>. Granted by /leader_skin/buy_set_item once the
|
||||||
|
/// viewer owns every skin in the series. Wire shape: entries inside
|
||||||
|
/// <c>rewards.items[]</c> on the per-series block of /leader_skin/products.
|
||||||
|
/// </summary>
|
||||||
|
[Owned]
|
||||||
|
public class LeaderSkinShopSeriesRewardEntry
|
||||||
|
{
|
||||||
|
public int OrderIndex { get; set; }
|
||||||
|
public int RewardType { get; set; }
|
||||||
|
public long RewardDetailId { get; set; }
|
||||||
|
public int RewardNumber { get; set; }
|
||||||
|
}
|
||||||
14
SVSim.Database/Models/ViewerLeaderSkinSetClaim.cs
Normal file
14
SVSim.Database/Models/ViewerLeaderSkinSetClaim.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per (viewer, leader-skin series) marking that the viewer has claimed the
|
||||||
|
/// series-completion bonus via /leader_skin/buy_set_item. Composite PK (ViewerId, SeriesId).
|
||||||
|
/// Standalone table (not a Viewer owned collection) to avoid the cartesian-explode pitfall
|
||||||
|
/// when loading the viewer graph — claim state is checked per-series, not per-viewer-load.
|
||||||
|
/// </summary>
|
||||||
|
public class ViewerLeaderSkinSetClaim
|
||||||
|
{
|
||||||
|
public long ViewerId { get; set; }
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
public DateTime ClaimedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ public class SVSimDbContext : DbContext
|
|||||||
public DbSet<SleeveShopSeriesEntry> SleeveShopSeries => Set<SleeveShopSeriesEntry>();
|
public DbSet<SleeveShopSeriesEntry> SleeveShopSeries => Set<SleeveShopSeriesEntry>();
|
||||||
public DbSet<SleeveShopProductEntry> SleeveShopProducts => Set<SleeveShopProductEntry>();
|
public DbSet<SleeveShopProductEntry> SleeveShopProducts => Set<SleeveShopProductEntry>();
|
||||||
public DbSet<ItemPurchaseCatalogEntry> ItemPurchaseCatalog => Set<ItemPurchaseCatalogEntry>();
|
public DbSet<ItemPurchaseCatalogEntry> ItemPurchaseCatalog => Set<ItemPurchaseCatalogEntry>();
|
||||||
|
public DbSet<LeaderSkinShopSeriesEntry> LeaderSkinShopSeries => Set<LeaderSkinShopSeriesEntry>();
|
||||||
|
public DbSet<LeaderSkinShopProductEntry> LeaderSkinShopProducts => Set<LeaderSkinShopProductEntry>();
|
||||||
|
public DbSet<ViewerLeaderSkinSetClaim> ViewerLeaderSkinSetClaims => Set<ViewerLeaderSkinSetClaim>();
|
||||||
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
|
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
|
||||||
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
||||||
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
||||||
@@ -191,6 +194,21 @@ public class SVSimDbContext : DbContext
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
modelBuilder.Entity<SleeveShopProductEntry>().HasIndex(p => p.SeriesId);
|
modelBuilder.Entity<SleeveShopProductEntry>().HasIndex(p => p.SeriesId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<LeaderSkinShopSeriesEntry>().OwnsMany(s => s.SetCompletionRewards);
|
||||||
|
modelBuilder.Entity<LeaderSkinShopProductEntry>().OwnsMany(p => p.Rewards);
|
||||||
|
modelBuilder.Entity<LeaderSkinShopProductEntry>()
|
||||||
|
.HasOne(p => p.Series)
|
||||||
|
.WithMany(s => s.Products)
|
||||||
|
.HasForeignKey(p => p.SeriesId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<LeaderSkinShopProductEntry>().HasIndex(p => p.SeriesId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ViewerLeaderSkinSetClaim>(b =>
|
||||||
|
{
|
||||||
|
b.HasKey(c => new { c.ViewerId, c.SeriesId });
|
||||||
|
b.HasIndex(c => c.ViewerId);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<CardCosmeticReward>(b =>
|
modelBuilder.Entity<CardCosmeticReward>(b =>
|
||||||
{
|
{
|
||||||
b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId });
|
b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId });
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.Database.Services;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin;
|
||||||
|
|
||||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// /leader_skin/* — per-class "active leader skin" preference. The per-CLASS setting is the
|
/// /leader_skin/* — the leader-skin shop family.
|
||||||
/// fallback used when a deck has <c>leader_skin_id == 0</c>; per-deck overrides go through
|
/// <list type="bullet">
|
||||||
/// /deck/update_leader_skin instead.
|
/// <item><c>/set</c>: per-class equipped-skin preference (the fallback when a deck has
|
||||||
|
/// <c>leader_skin_id == 0</c>). Per-deck overrides go through /deck/update_leader_skin.</item>
|
||||||
|
/// <item><c>/products</c>: shop catalog (dict-keyed by series_id).</item>
|
||||||
|
/// <item><c>/buy</c>: single-skin purchase. Currency dispatch crystal/rupy/ticket(501).</item>
|
||||||
|
/// <item><c>/buy_set</c>: whole-series purchase at set discount.</item>
|
||||||
|
/// <item><c>/buy_set_item</c>: claim series-completion bonus (idempotent via
|
||||||
|
/// <see cref="ViewerLeaderSkinSetClaim"/>).</item>
|
||||||
|
/// <item><c>/ids</c>: flat list of owned skin ids for badge refresh.</item>
|
||||||
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("leader_skin")]
|
[Route("leader_skin")]
|
||||||
public class LeaderSkinController : SVSimController
|
public class LeaderSkinController : SVSimController
|
||||||
{
|
{
|
||||||
private readonly SVSimDbContext _db;
|
private readonly SVSimDbContext _db;
|
||||||
|
private readonly RewardGrantService _rewards;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
|
||||||
public LeaderSkinController(SVSimDbContext db)
|
public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_rewards = rewards;
|
||||||
|
_time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("set")]
|
[HttpPost("set")]
|
||||||
@@ -28,8 +44,6 @@ public class LeaderSkinController : SVSimController
|
|||||||
|
|
||||||
if (request.IsRandomLeaderSkin)
|
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,
|
return StatusCode(StatusCodes.Status501NotImplemented,
|
||||||
new { error = "random_leader_skin_not_implemented" });
|
new { error = "random_leader_skin_not_implemented" });
|
||||||
}
|
}
|
||||||
@@ -44,7 +58,6 @@ public class LeaderSkinController : SVSimController
|
|||||||
var classData = viewer.Classes.FirstOrDefault(c => c.Class.Id == request.ClassId);
|
var classData = viewer.Classes.FirstOrDefault(c => c.Class.Id == request.ClassId);
|
||||||
if (classData is null) return BadRequest(new { error = "unknown_class" });
|
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);
|
var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
|
||||||
if (skin is null) return BadRequest(new { error = "unknown_skin" });
|
if (skin is null) return BadRequest(new { error = "unknown_skin" });
|
||||||
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
|
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
|
||||||
@@ -61,4 +74,336 @@ public class LeaderSkinController : SVSimController
|
|||||||
LeaderSkinIdList = new(),
|
LeaderSkinIdList = new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("ids")]
|
||||||
|
public async Task<ActionResult<LeaderSkinIdsResponse>> Ids()
|
||||||
|
{
|
||||||
|
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||||
|
|
||||||
|
var ids = await _db.Viewers
|
||||||
|
.Where(v => v.Id == viewerId)
|
||||||
|
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
|
||||||
|
.OrderBy(id => id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("products")]
|
||||||
|
public async Task<ActionResult<Dictionary<string, SkinSeriesDto>>> Products()
|
||||||
|
{
|
||||||
|
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||||
|
|
||||||
|
var ownedSkinIds = (await _db.Viewers
|
||||||
|
.Where(v => v.Id == viewerId)
|
||||||
|
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
|
||||||
|
.ToListAsync()).ToHashSet();
|
||||||
|
|
||||||
|
var claimedSeries = (await _db.ViewerLeaderSkinSetClaims
|
||||||
|
.Where(c => c.ViewerId == viewerId)
|
||||||
|
.Select(c => c.SeriesId)
|
||||||
|
.ToListAsync()).ToHashSet();
|
||||||
|
|
||||||
|
var series = await _db.LeaderSkinShopSeries
|
||||||
|
.Where(s => s.IsEnabled)
|
||||||
|
.Include(s => s.SetCompletionRewards)
|
||||||
|
.Include(s => s.Products.Where(p => p.IsEnabled)).ThenInclude(p => p.Rewards)
|
||||||
|
.OrderBy(s => s.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = new Dictionary<string, SkinSeriesDto>();
|
||||||
|
foreach (var s in series)
|
||||||
|
{
|
||||||
|
var products = s.Products.OrderBy(p => p.Id).Select(p => ToProductDto(p, ownedSkinIds)).ToList();
|
||||||
|
bool seriesCompleted = products.Count > 0 && products.All(p => p.IsPurchased);
|
||||||
|
int rewardStatus = ComputeRewardStatus(s, seriesCompleted, claimedSeries.Contains(s.Id));
|
||||||
|
|
||||||
|
result[s.Id.ToString()] = new SkinSeriesDto
|
||||||
|
{
|
||||||
|
SeriesId = s.Id,
|
||||||
|
IsCompleted = seriesCompleted,
|
||||||
|
IsNew = s.IsNew,
|
||||||
|
SetSalesStatus = s.SetSalesStatus,
|
||||||
|
Rewards = new SkinSeriesRewardsDto
|
||||||
|
{
|
||||||
|
Status = rewardStatus,
|
||||||
|
Items = s.SetCompletionRewards.OrderBy(r => r.OrderIndex).Select(r => new SkinSeriesRewardItemDto
|
||||||
|
{
|
||||||
|
RewardType = r.RewardType,
|
||||||
|
RewardDetailId = r.RewardDetailId,
|
||||||
|
RewardNumber = r.RewardNumber,
|
||||||
|
}).ToList(),
|
||||||
|
},
|
||||||
|
SetPrices = new SkinSeriesSetPricesDto
|
||||||
|
{
|
||||||
|
SetPriceCrystal = s.SetPriceCrystal,
|
||||||
|
SetPriceRupy = s.SetPriceRupy,
|
||||||
|
SetPriceTicket = s.SetPriceTicket,
|
||||||
|
TicketId = s.SetPriceTicketId,
|
||||||
|
},
|
||||||
|
Products = products,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("buy")]
|
||||||
|
public async Task<ActionResult<LeaderSkinBuyResponse>> Buy(LeaderSkinBuyRequest request)
|
||||||
|
{
|
||||||
|
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||||
|
|
||||||
|
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 product = await _db.LeaderSkinShopProducts
|
||||||
|
.Include(p => p.Rewards)
|
||||||
|
.Include(p => p.Series)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == 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" });
|
||||||
|
|
||||||
|
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||||
|
|
||||||
|
// Already-purchased = viewer owns the leader_skin this product grants.
|
||||||
|
if (viewer.LeaderSkins.Any(s => s.Id == product.LeaderSkinId))
|
||||||
|
return BadRequest(new { error = "already_purchased" });
|
||||||
|
|
||||||
|
var rewardList = new List<RewardListEntry>();
|
||||||
|
var debit = DebitProductPrice(viewer, product, request.SalesType);
|
||||||
|
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||||
|
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||||
|
|
||||||
|
await ApplyRewardsAsync(viewer, product.Rewards, rewardList);
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new LeaderSkinBuyResponse { RewardList = rewardList };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("buy_set")]
|
||||||
|
public async Task<ActionResult<LeaderSkinBuyResponse>> BuySet(LeaderSkinBuySetRequest request)
|
||||||
|
{
|
||||||
|
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||||
|
|
||||||
|
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 series = await _db.LeaderSkinShopSeries
|
||||||
|
.Include(s => s.Products.Where(p => p.IsEnabled)).ThenInclude(p => p.Rewards)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == request.SeriesId);
|
||||||
|
if (series is null) return NotFound(new { error = "unknown_series" });
|
||||||
|
if (!series.IsEnabled || series.SetSalesStatus == 0)
|
||||||
|
return BadRequest(new { error = "set_sale_not_active" });
|
||||||
|
|
||||||
|
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||||
|
|
||||||
|
var rewardList = new List<RewardListEntry>();
|
||||||
|
var debit = DebitSetPrice(viewer, series, request.SalesType);
|
||||||
|
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||||
|
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||||
|
|
||||||
|
// Grant every product's rewards; RewardGrantService is idempotent on already-owned
|
||||||
|
// cosmetics, so partial-set buyers don't double-add.
|
||||||
|
foreach (var p in series.Products.OrderBy(p => p.Id))
|
||||||
|
{
|
||||||
|
await ApplyRewardsAsync(viewer, p.Rewards, rewardList);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new LeaderSkinBuyResponse { RewardList = rewardList };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("buy_set_item")]
|
||||||
|
public async Task<ActionResult<LeaderSkinBuyResponse>> BuySetItem(LeaderSkinBuySetItemRequest request)
|
||||||
|
{
|
||||||
|
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||||
|
|
||||||
|
var series = await _db.LeaderSkinShopSeries
|
||||||
|
.Include(s => s.SetCompletionRewards)
|
||||||
|
.Include(s => s.Products.Where(p => p.IsEnabled))
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == request.SeriesId);
|
||||||
|
if (series is null) return NotFound(new { error = "unknown_series" });
|
||||||
|
|
||||||
|
// Check claim hasn't been made already (idempotent — returns empty reward_list rather
|
||||||
|
// than 400 so the client doesn't error if it retries).
|
||||||
|
var existingClaim = await _db.ViewerLeaderSkinSetClaims
|
||||||
|
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.SeriesId == series.Id);
|
||||||
|
if (existingClaim is not null)
|
||||||
|
return new LeaderSkinBuyResponse { RewardList = new() };
|
||||||
|
|
||||||
|
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||||
|
|
||||||
|
// Must own every skin in the series to claim the bonus.
|
||||||
|
var ownedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet();
|
||||||
|
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => ownedSkinIds.Contains(p.LeaderSkinId));
|
||||||
|
if (!ownsAll)
|
||||||
|
return BadRequest(new { error = "series_not_completed" });
|
||||||
|
|
||||||
|
var rewardList = new List<RewardListEntry>();
|
||||||
|
await ApplyRewardsAsync(viewer, series.SetCompletionRewards, rewardList);
|
||||||
|
|
||||||
|
_db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim
|
||||||
|
{
|
||||||
|
ViewerId = viewerId,
|
||||||
|
SeriesId = series.Id,
|
||||||
|
ClaimedAt = _time.GetUtcNow().UtcDateTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new LeaderSkinBuyResponse { RewardList = rewardList };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the per-viewer <c>rewards.status</c> for a series:
|
||||||
|
/// 0=none — set_sales_status==0 (no set sale active)
|
||||||
|
/// 1=not_got — series completed by viewer but bonus unclaimed
|
||||||
|
/// 2=got — viewer claimed the bonus
|
||||||
|
/// 1 (effectively "available later") when set sale active but viewer hasn't completed it.
|
||||||
|
/// The 1/2 distinction matches the client enum (RewardStatus.not_got vs .got).
|
||||||
|
/// </summary>
|
||||||
|
private static int ComputeRewardStatus(LeaderSkinShopSeriesEntry series, bool seriesCompleted, bool claimed)
|
||||||
|
{
|
||||||
|
if (series.SetSalesStatus == 0) return 0;
|
||||||
|
if (claimed) return 2;
|
||||||
|
if (seriesCompleted) return 1;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, HashSet<int> ownedSkinIds)
|
||||||
|
{
|
||||||
|
bool isPurchased = ownedSkinIds.Contains(p.LeaderSkinId);
|
||||||
|
return new SkinProductDto
|
||||||
|
{
|
||||||
|
ProductId = p.Id,
|
||||||
|
LeaderSkinId = p.LeaderSkinId,
|
||||||
|
ProductName = p.ProductNameKey,
|
||||||
|
Introduction = p.IntroductionKey,
|
||||||
|
CvName = p.CvNameKey,
|
||||||
|
IsPurchased = isPurchased,
|
||||||
|
Sale = new SkinProductSaleDto
|
||||||
|
{
|
||||||
|
SinglePriceCrystal = p.SinglePriceCrystal,
|
||||||
|
SinglePriceRupy = p.SinglePriceRupy,
|
||||||
|
SinglePriceTicket = p.SinglePriceTicket,
|
||||||
|
TicketNumber = p.TicketNumber,
|
||||||
|
ItemId = p.TicketItemId,
|
||||||
|
},
|
||||||
|
Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SkinProductRewardDto
|
||||||
|
{
|
||||||
|
RewardType = r.RewardType,
|
||||||
|
RewardDetailId = r.RewardDetailId,
|
||||||
|
RewardNumber = r.RewardNumber,
|
||||||
|
IsOwned = IsRewardOwned(r, ownedSkinIds),
|
||||||
|
}).ToList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A bundled reward shows as "owned" when the viewer already has the cosmetic. For now we
|
||||||
|
/// only flag the Skin reward (type==10) against the viewer's skin collection — the cascaded
|
||||||
|
/// emblem/sleeve typically come with the skin, so the heuristic is "skin owned → all three
|
||||||
|
/// bundle items are de-facto owned." Refine later if a capture shows independent state.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, HashSet<int> ownedSkinIds)
|
||||||
|
{
|
||||||
|
// Skin reward: direct check.
|
||||||
|
if (r.RewardType == (int)UserGoodsType.Skin)
|
||||||
|
return ownedSkinIds.Contains((int)r.RewardDetailId);
|
||||||
|
// Other types: we don't have the full cosmetic-owned graph in scope here. The product's
|
||||||
|
// sibling Skin reward tells us whether the bundle was purchased; piggy-back on that by
|
||||||
|
// letting the caller pre-compute IsPurchased. Conservative default: not owned.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (RewardListEntry? PostState, string? Error) DebitProductPrice(
|
||||||
|
Viewer viewer, LeaderSkinShopProductEntry product, int salesType)
|
||||||
|
{
|
||||||
|
return salesType switch
|
||||||
|
{
|
||||||
|
0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0 => (null, null),
|
||||||
|
0 => (null, "price_not_available_for_currency"),
|
||||||
|
1 => product.SinglePriceCrystal is null
|
||||||
|
? (null, "price_not_available_for_currency")
|
||||||
|
: DebitCrystal(viewer, product.SinglePriceCrystal.Value),
|
||||||
|
2 => product.SinglePriceRupy is null
|
||||||
|
? (null, "price_not_available_for_currency")
|
||||||
|
: DebitRupy(viewer, product.SinglePriceRupy.Value),
|
||||||
|
_ => (null, "invalid_sales_type"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private (RewardListEntry? PostState, string? Error) DebitSetPrice(
|
||||||
|
Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType)
|
||||||
|
{
|
||||||
|
return salesType switch
|
||||||
|
{
|
||||||
|
0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0 => (null, null),
|
||||||
|
0 => (null, "price_not_available_for_currency"),
|
||||||
|
1 => series.SetPriceCrystal is null
|
||||||
|
? (null, "price_not_available_for_currency")
|
||||||
|
: DebitCrystal(viewer, series.SetPriceCrystal.Value),
|
||||||
|
2 => series.SetPriceRupy is null
|
||||||
|
? (null, "price_not_available_for_currency")
|
||||||
|
: DebitRupy(viewer, series.SetPriceRupy.Value),
|
||||||
|
_ => (null, "invalid_sales_type"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (RewardListEntry?, string?) DebitCrystal(Viewer viewer, int amount)
|
||||||
|
{
|
||||||
|
if (viewer.Currency.Crystals < (ulong)amount) return (null, "insufficient_crystals");
|
||||||
|
viewer.Currency.Crystals -= (ulong)amount;
|
||||||
|
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (RewardListEntry?, string?) DebitRupy(Viewer viewer, int amount)
|
||||||
|
{
|
||||||
|
if (viewer.Currency.Rupees < (ulong)amount) return (null, "insufficient_rupees");
|
||||||
|
viewer.Currency.Rupees -= (ulong)amount;
|
||||||
|
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyRewardsAsync<T>(
|
||||||
|
Viewer viewer, IEnumerable<T> rewards, List<RewardListEntry> rewardList) where T : notnull
|
||||||
|
{
|
||||||
|
foreach (var r in rewards)
|
||||||
|
{
|
||||||
|
var (type, detailId, number) = ExtractTuple(r);
|
||||||
|
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)type, detailId, number);
|
||||||
|
foreach (var g in granted)
|
||||||
|
{
|
||||||
|
rewardList.Add(new RewardListEntry
|
||||||
|
{
|
||||||
|
RewardType = g.RewardType,
|
||||||
|
RewardId = g.RewardId,
|
||||||
|
RewardNum = g.RewardNum,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int Type, long Id, int Num) ExtractTuple(object reward) => reward switch
|
||||||
|
{
|
||||||
|
LeaderSkinShopProductRewardEntry p => (p.RewardType, p.RewardDetailId, p.RewardNumber),
|
||||||
|
LeaderSkinShopSeriesRewardEntry s => (s.RewardType, s.RewardDetailId, s.RewardNumber),
|
||||||
|
_ => throw new InvalidOperationException($"unexpected reward type {reward.GetType().Name}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
|
||||||
|
.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.Cards).ThenInclude(c => c.Card)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.FirstAsync(v => v.Id == viewerId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// /leader_skin/buy request body. sales_type is ShopCommonUtility.SalesType:
|
||||||
|
/// 0=free, 1=crystal, 2=rupy, 3=ticket (v1: 3 returns 501 — no ticket-priced skin captured).
|
||||||
|
/// <see cref="ItemId"/> is the ticket item id when paying with a ticket, null otherwise.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public class LeaderSkinBuyRequest : BaseRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("product_id")]
|
||||||
|
[Key("product_id")]
|
||||||
|
public int ProductId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("sales_type")]
|
||||||
|
[Key("sales_type")]
|
||||||
|
public int SalesType { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("item_id")]
|
||||||
|
[Key("item_id")]
|
||||||
|
public long? ItemId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// /leader_skin/buy_set_item — claim the series-completion bonus once every skin in the series
|
||||||
|
/// is owned. <c>sales_type</c> field exists on the client's param class but is never set; server
|
||||||
|
/// ignores it.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public class LeaderSkinBuySetItemRequest : BaseRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("series_id")]
|
||||||
|
[Key("series_id")]
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// /leader_skin/buy_set — purchase every skin in a series in one call (cheaper per-skin).
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public class LeaderSkinBuySetRequest : BaseRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("series_id")]
|
||||||
|
[Key("series_id")]
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("sales_type")]
|
||||||
|
[Key("sales_type")]
|
||||||
|
public int SalesType { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("item_id")]
|
||||||
|
[Key("item_id")]
|
||||||
|
public long? ItemId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// /leader_skin/buy, /leader_skin/buy_set, /leader_skin/buy_set_item all return the same shape:
|
||||||
|
/// a <c>reward_list</c> of standard <see cref="RewardListEntry"/> entries (post-state totals
|
||||||
|
/// for currencies, grant counts for cosmetics).
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public class LeaderSkinBuyResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("reward_list")]
|
||||||
|
[Key("reward_list")]
|
||||||
|
public List<RewardListEntry> RewardList { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// /leader_skin/ids — flat list of leader_skin_ids the viewer owns. Used by the client to
|
||||||
|
/// refresh badges across the skin-selection UI without re-fetching the full shop catalog.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public class LeaderSkinIdsResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("user_leader_skin_ids")]
|
||||||
|
[Key("user_leader_skin_ids")]
|
||||||
|
public List<int> UserLeaderSkinIds { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin;
|
||||||
|
|
||||||
|
// /leader_skin/products wire shape: `data` IS the per-series dict (no wrapping field).
|
||||||
|
// Per SkinPurchaseInfoTask.Parse line 31: `JsonData jsonData = base.ResponseData["data"];`
|
||||||
|
// then iterates positionally. Dict-keyed-by-series_id_string mirrors the prod capture exactly.
|
||||||
|
// The controller returns Dictionary<string, SkinSeriesDto> directly so `data` becomes that dict.
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public class SkinSeriesDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("series_id")]
|
||||||
|
[Key("series_id")]
|
||||||
|
public int SeriesId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when the viewer owns every product's skin in this series.</summary>
|
||||||
|
[JsonPropertyName("is_completed")]
|
||||||
|
[Key("is_completed")]
|
||||||
|
public bool IsCompleted { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("is_new")]
|
||||||
|
[Key("is_new")]
|
||||||
|
public bool IsNew { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Always emit — client unconditionally calls .ToInt() on this field.</summary>
|
||||||
|
[JsonPropertyName("set_sales_status")]
|
||||||
|
[Key("set_sales_status")]
|
||||||
|
public int SetSalesStatus { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rewards")]
|
||||||
|
[Key("rewards")]
|
||||||
|
public SkinSeriesRewardsDto Rewards { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Always emit — client reads this dict when set_sales_status != 0.</summary>
|
||||||
|
[JsonPropertyName("set_prices")]
|
||||||
|
[Key("set_prices")]
|
||||||
|
public SkinSeriesSetPricesDto SetPrices { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("sales_period_info")]
|
||||||
|
[Key("sales_period_info")]
|
||||||
|
public List<object> SalesPeriodInfo { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("products")]
|
||||||
|
[Key("products")]
|
||||||
|
public List<SkinProductDto> Products { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public class SkinSeriesRewardsDto
|
||||||
|
{
|
||||||
|
/// <summary>SkinSeriesPurchaseInfo.RewardStatus — 0=none, 1=not_got, 2=got.</summary>
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
[Key("status")]
|
||||||
|
public int Status { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
[Key("items")]
|
||||||
|
public List<SkinSeriesRewardItemDto> Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public class SkinSeriesRewardItemDto
|
||||||
|
{
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public class SkinSeriesSetPricesDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("set_price_crystal")]
|
||||||
|
[Key("set_price_crystal")]
|
||||||
|
public int? SetPriceCrystal { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("set_price_rupy")]
|
||||||
|
[Key("set_price_rupy")]
|
||||||
|
public int? SetPriceRupy { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("set_price_ticket")]
|
||||||
|
[Key("set_price_ticket")]
|
||||||
|
public int? SetPriceTicket { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("ticket_id")]
|
||||||
|
[Key("ticket_id")]
|
||||||
|
public long? TicketId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public class SkinProductDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("product_id")]
|
||||||
|
[Key("product_id")]
|
||||||
|
public int ProductId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("leader_skin_id")]
|
||||||
|
[Key("leader_skin_id")]
|
||||||
|
public int LeaderSkinId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("product_name")]
|
||||||
|
[Key("product_name")]
|
||||||
|
public string ProductName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("introduction")]
|
||||||
|
[Key("introduction")]
|
||||||
|
public string Introduction { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("cv_name")]
|
||||||
|
[Key("cv_name")]
|
||||||
|
public string CvName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("is_purchased")]
|
||||||
|
[Key("is_purchased")]
|
||||||
|
public bool IsPurchased { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("sale")]
|
||||||
|
[Key("sale")]
|
||||||
|
public SkinProductSaleDto Sale { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("sales_period_info")]
|
||||||
|
[Key("sales_period_info")]
|
||||||
|
public List<object> SalesPeriodInfo { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("rewards")]
|
||||||
|
[Key("rewards")]
|
||||||
|
public List<SkinProductRewardDto> Rewards { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public class SkinProductSaleDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("single_price_crystal")]
|
||||||
|
[Key("single_price_crystal")]
|
||||||
|
public int? SinglePriceCrystal { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("single_price_rupy")]
|
||||||
|
[Key("single_price_rupy")]
|
||||||
|
public int? SinglePriceRupy { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("single_price_ticket")]
|
||||||
|
[Key("single_price_ticket")]
|
||||||
|
public int? SinglePriceTicket { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("ticket_number")]
|
||||||
|
[Key("ticket_number")]
|
||||||
|
public int? TicketNumber { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("item_id")]
|
||||||
|
[Key("item_id")]
|
||||||
|
public long? ItemId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public class SkinProductRewardDto
|
||||||
|
{
|
||||||
|
[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("is_owned")]
|
||||||
|
[Key("is_owned")]
|
||||||
|
public bool IsOwned { get; set; }
|
||||||
|
}
|
||||||
297
SVSim.UnitTests/Controllers/LeaderSkinShopControllerTests.cs
Normal file
297
SVSim.UnitTests/Controllers/LeaderSkinShopControllerTests.cs
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the four new shop endpoints (/products, /buy, /buy_set, /buy_set_item, /ids).
|
||||||
|
/// Existing /set tests live in the older smoke-test files; this class only covers the new
|
||||||
|
/// surface added with the leader-skin-shop family.
|
||||||
|
/// </summary>
|
||||||
|
public class LeaderSkinShopControllerTests
|
||||||
|
{
|
||||||
|
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds one series (9001) with 2 products (skins 9101, 9102). Each product grants
|
||||||
|
/// only its skin (no emblem/sleeve cascade — keeps the test self-contained without
|
||||||
|
/// touching the cosmetic catalog). Set sale active at 800 crystals / 800 rupy.
|
||||||
|
/// Set-completion bonus is 500 rupy.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task SeedShop(SVSimTestFactory f)
|
||||||
|
{
|
||||||
|
using var scope = f.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
// LeaderSkin cosmetic catalog rows (RewardGrantService.AddCosmeticIfMissing looks these up)
|
||||||
|
if (!await db.LeaderSkins.AnyAsync(s => s.Id == 9101))
|
||||||
|
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 9101, ClassId = 1 });
|
||||||
|
if (!await db.LeaderSkins.AnyAsync(s => s.Id == 9102))
|
||||||
|
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 9102, ClassId = 1 });
|
||||||
|
|
||||||
|
db.LeaderSkinShopSeries.Add(new LeaderSkinShopSeriesEntry
|
||||||
|
{
|
||||||
|
Id = 9001, IsEnabled = true, IsNew = false,
|
||||||
|
SetSalesStatus = 1, SetPriceCrystal = 800, SetPriceRupy = 800,
|
||||||
|
Products =
|
||||||
|
{
|
||||||
|
new LeaderSkinShopProductEntry
|
||||||
|
{
|
||||||
|
Id = 90011, SeriesId = 9001, LeaderSkinId = 9101,
|
||||||
|
ProductNameKey = "LSPPN_test_1", IntroductionKey = "LSPI_test_1", CvNameKey = "LSPCN_test_1",
|
||||||
|
SinglePriceCrystal = 500, SinglePriceRupy = 500, IsEnabled = true,
|
||||||
|
Rewards = { new LeaderSkinShopProductRewardEntry { OrderIndex = 0, RewardType = 10, RewardDetailId = 9101, RewardNumber = 1 } },
|
||||||
|
},
|
||||||
|
new LeaderSkinShopProductEntry
|
||||||
|
{
|
||||||
|
Id = 90012, SeriesId = 9001, LeaderSkinId = 9102,
|
||||||
|
ProductNameKey = "LSPPN_test_2", IntroductionKey = "LSPI_test_2", CvNameKey = "LSPCN_test_2",
|
||||||
|
SinglePriceCrystal = 500, SinglePriceRupy = 500, IsEnabled = true,
|
||||||
|
Rewards = { new LeaderSkinShopProductRewardEntry { OrderIndex = 0, RewardType = 10, RewardDetailId = 9102, RewardNumber = 1 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SetCompletionRewards =
|
||||||
|
{
|
||||||
|
new LeaderSkinShopSeriesRewardEntry { OrderIndex = 0, RewardType = 9, RewardDetailId = 0, RewardNumber = 500 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SetViewerCurrency(SVSimTestFactory f, long viewerId, ulong crystals = 0, ulong rupies = 0)
|
||||||
|
{
|
||||||
|
using var scope = f.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||||
|
v.Currency.Crystals = crystals;
|
||||||
|
v.Currency.Rupees = rupies;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Products_returns_dict_keyed_by_series_id_with_set_fields_emitted()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
await SeedShop(factory);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var response = await client.PostAsync("/leader_skin/products",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.That(root.ValueKind, Is.EqualTo(JsonValueKind.Object), "wire shape is dict-keyed by series_id string");
|
||||||
|
|
||||||
|
var series = root.GetProperty("9001");
|
||||||
|
Assert.That(series.GetProperty("series_id").GetInt32(), Is.EqualTo(9001));
|
||||||
|
Assert.That(series.GetProperty("set_sales_status").GetInt32(), Is.EqualTo(1));
|
||||||
|
Assert.That(series.GetProperty("set_prices").GetProperty("set_price_crystal").GetInt32(), Is.EqualTo(800));
|
||||||
|
|
||||||
|
var products = series.GetProperty("products");
|
||||||
|
Assert.That(products.GetArrayLength(), Is.EqualTo(2));
|
||||||
|
Assert.That(products[0].GetProperty("is_purchased").GetBoolean(), Is.False);
|
||||||
|
|
||||||
|
// set_completion bonus item should be in rewards.items
|
||||||
|
var rewards = series.GetProperty("rewards");
|
||||||
|
Assert.That(rewards.GetProperty("items").GetArrayLength(), Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Buy_single_crystal_debits_and_grants_skin()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
await SeedShop(factory);
|
||||||
|
await SetViewerCurrency(factory, viewerId, crystals: 1000);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var response = await client.PostAsync("/leader_skin/buy",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":90011,"sales_type":1,"item_id":null}"""));
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var rewardList = doc.RootElement.GetProperty("reward_list");
|
||||||
|
Assert.That(rewardList.GetArrayLength(), Is.EqualTo(2)); // crystal post-state + skin grant
|
||||||
|
|
||||||
|
var crystal = rewardList[0];
|
||||||
|
Assert.That(crystal.GetProperty("reward_type").GetInt32(), Is.EqualTo(2));
|
||||||
|
Assert.That(crystal.GetProperty("reward_num").GetInt32(), Is.EqualTo(500));
|
||||||
|
|
||||||
|
var skin = rewardList[1];
|
||||||
|
Assert.That(skin.GetProperty("reward_type").GetInt32(), Is.EqualTo(10));
|
||||||
|
Assert.That(skin.GetProperty("reward_id").GetInt64(), Is.EqualTo(9101));
|
||||||
|
|
||||||
|
// Viewer should now own skin 9101
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var v = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||||
|
Assert.That(v.LeaderSkins.Any(s => s.Id == 9101), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Buy_already_purchased_skin_rejects_with_400()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
await SeedShop(factory);
|
||||||
|
await SetViewerCurrency(factory, viewerId, crystals: 1000);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var first = await client.PostAsync("/leader_skin/buy",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":90011,"sales_type":1,"item_id":null}"""));
|
||||||
|
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
var second = await client.PostAsync("/leader_skin/buy",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":90011,"sales_type":1,"item_id":null}"""));
|
||||||
|
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Buy_ticket_sales_type_returns_501()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
await SeedShop(factory);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var response = await client.PostAsync("/leader_skin/buy",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":90011,"sales_type":3,"item_id":900001}"""));
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BuySet_grants_all_skins_in_series()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
await SeedShop(factory);
|
||||||
|
await SetViewerCurrency(factory, viewerId, crystals: 1000);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var response = await client.PostAsync("/leader_skin/buy_set",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9001,"sales_type":1,"item_id":null}"""));
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var rewardList = doc.RootElement.GetProperty("reward_list");
|
||||||
|
Assert.That(rewardList.GetArrayLength(), Is.EqualTo(3)); // crystal post + skin1 + skin2
|
||||||
|
|
||||||
|
// Viewer should own both skins
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var v = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||||
|
Assert.That(v.LeaderSkins.Count(s => s.Id == 9101 || s.Id == 9102), Is.EqualTo(2));
|
||||||
|
Assert.That(v.Currency.Crystals, Is.EqualTo(200UL)); // 1000 - 800 set price
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BuySetItem_rejects_until_series_completed_then_succeeds()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
await SeedShop(factory);
|
||||||
|
await SetViewerCurrency(factory, viewerId, rupies: 5000);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
// Without owning any skin: rejected
|
||||||
|
var early = await client.PostAsync("/leader_skin/buy_set_item",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9001}"""));
|
||||||
|
Assert.That(early.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
|
||||||
|
// Buy both skins via buy_set
|
||||||
|
var setBuy = await client.PostAsync("/leader_skin/buy_set",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9001,"sales_type":2,"item_id":null}"""));
|
||||||
|
Assert.That(setBuy.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
// Now claim succeeds, grants the bonus (500 rupy)
|
||||||
|
var claim = await client.PostAsync("/leader_skin/buy_set_item",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9001}"""));
|
||||||
|
var body = await claim.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(claim.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var rewardList = doc.RootElement.GetProperty("reward_list");
|
||||||
|
Assert.That(rewardList.GetArrayLength(), Is.EqualTo(1));
|
||||||
|
Assert.That(rewardList[0].GetProperty("reward_type").GetInt32(), Is.EqualTo(9)); // Rupy
|
||||||
|
|
||||||
|
// Second claim returns OK with empty reward_list (idempotent — not 400)
|
||||||
|
var second = await client.PostAsync("/leader_skin/buy_set_item",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9001}"""));
|
||||||
|
var secondBody = await second.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
using var doc2 = JsonDocument.Parse(secondBody);
|
||||||
|
Assert.That(doc2.RootElement.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Ids_returns_owned_leader_skin_ids()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
await SeedShop(factory);
|
||||||
|
await SetViewerCurrency(factory, viewerId, crystals: 1000);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
// Initial state: no owned skins from our shop
|
||||||
|
var beforeResp = await client.PostAsync("/leader_skin/ids",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
|
||||||
|
var beforeBody = await beforeResp.Content.ReadAsStringAsync();
|
||||||
|
using var beforeDoc = JsonDocument.Parse(beforeBody);
|
||||||
|
bool ownsBefore = false;
|
||||||
|
foreach (var id in beforeDoc.RootElement.GetProperty("user_leader_skin_ids").EnumerateArray())
|
||||||
|
if (id.GetInt32() == 9101) { ownsBefore = true; break; }
|
||||||
|
Assert.That(ownsBefore, Is.False);
|
||||||
|
|
||||||
|
// Buy skin 9101
|
||||||
|
await client.PostAsync("/leader_skin/buy",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":90011,"sales_type":1,"item_id":null}"""));
|
||||||
|
|
||||||
|
var afterResp = await client.PostAsync("/leader_skin/ids",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
|
||||||
|
var afterBody = await afterResp.Content.ReadAsStringAsync();
|
||||||
|
using var afterDoc = JsonDocument.Parse(afterBody);
|
||||||
|
bool ownsAfter = false;
|
||||||
|
foreach (var id in afterDoc.RootElement.GetProperty("user_leader_skin_ids").EnumerateArray())
|
||||||
|
if (id.GetInt32() == 9101) { ownsAfter = true; break; }
|
||||||
|
Assert.That(ownsAfter, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BuySet_on_series_without_set_sale_rejects()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
db.LeaderSkinShopSeries.Add(new LeaderSkinShopSeriesEntry
|
||||||
|
{
|
||||||
|
Id = 9999, IsEnabled = true, SetSalesStatus = 0, // no set sale
|
||||||
|
Products = { new LeaderSkinShopProductEntry { Id = 99991, SeriesId = 9999, LeaderSkinId = 1, IsEnabled = true, SinglePriceCrystal = 500 } },
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
await SetViewerCurrency(factory, viewerId, crystals: 1000);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var response = await client.PostAsync("/leader_skin/buy_set",
|
||||||
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9999,"sales_type":1,"item_id":null}"""));
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
}
|
||||||
|
}
|
||||||
65
SVSim.UnitTests/Importers/LeaderSkinShopImporterTests.cs
Normal file
65
SVSim.UnitTests/Importers/LeaderSkinShopImporterTests.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Bootstrap.Importers;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Importers;
|
||||||
|
|
||||||
|
public class LeaderSkinShopImporterTests
|
||||||
|
{
|
||||||
|
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Imports_series_products_and_set_rewards_from_seed_file()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
await new LeaderSkinShopImporter().ImportAsync(db, SeedDir);
|
||||||
|
|
||||||
|
var series = await db.LeaderSkinShopSeries
|
||||||
|
.Include(s => s.SetCompletionRewards)
|
||||||
|
.Include(s => s.Products).ThenInclude(p => p.Rewards)
|
||||||
|
.OrderBy(s => s.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Assert.That(series.Count, Is.GreaterThan(0));
|
||||||
|
|
||||||
|
// Spot-check series 100 (Shingeki no Bahamut) — set sale active with 2000c/2000r set price
|
||||||
|
var s100 = series.First(s => s.Id == 100);
|
||||||
|
Assert.That(s100.SetSalesStatus, Is.EqualTo(1));
|
||||||
|
Assert.That(s100.SetPriceCrystal, Is.EqualTo(2000));
|
||||||
|
Assert.That(s100.SetPriceRupy, Is.EqualTo(2000));
|
||||||
|
Assert.That(s100.Products.Count, Is.GreaterThan(0));
|
||||||
|
|
||||||
|
// Spot-check a series with set-completion rewards (series 103 in capture has 2)
|
||||||
|
var withRewards = series.FirstOrDefault(s => s.SetCompletionRewards.Count > 0);
|
||||||
|
Assert.That(withRewards, Is.Not.Null, "at least one series should have set-completion rewards");
|
||||||
|
Assert.That(withRewards!.SetCompletionRewards.All(r => r.RewardDetailId > 0), Is.True);
|
||||||
|
|
||||||
|
// Per-product rewards (the captured shape — skin + emblem + sleeve triplet)
|
||||||
|
var firstProduct = s100.Products.OrderBy(p => p.Id).First();
|
||||||
|
Assert.That(firstProduct.LeaderSkinId, Is.GreaterThan(0));
|
||||||
|
Assert.That(firstProduct.Rewards.Count, Is.EqualTo(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Is_idempotent_on_rerun()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
await new LeaderSkinShopImporter().ImportAsync(db, SeedDir);
|
||||||
|
int seriesBefore = await db.LeaderSkinShopSeries.CountAsync();
|
||||||
|
int productsBefore = await db.LeaderSkinShopProducts.CountAsync();
|
||||||
|
|
||||||
|
await new LeaderSkinShopImporter().ImportAsync(db, SeedDir);
|
||||||
|
|
||||||
|
Assert.That(await db.LeaderSkinShopSeries.CountAsync(), Is.EqualTo(seriesBefore));
|
||||||
|
Assert.That(await db.LeaderSkinShopProducts.CountAsync(), Is.EqualTo(productsBefore));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -211,6 +211,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
|||||||
await new ItemImporter().ImportAsync(ctx, seedDir);
|
await new ItemImporter().ImportAsync(ctx, seedDir);
|
||||||
await new SleeveShopImporter().ImportAsync(ctx, seedDir);
|
await new SleeveShopImporter().ImportAsync(ctx, seedDir);
|
||||||
await new ItemPurchaseImporter().ImportAsync(ctx, seedDir);
|
await new ItemPurchaseImporter().ImportAsync(ctx, seedDir);
|
||||||
|
await new LeaderSkinShopImporter().ImportAsync(ctx, seedDir);
|
||||||
var puzzleImporter = new PuzzleImporter();
|
var puzzleImporter = new PuzzleImporter();
|
||||||
await puzzleImporter.ImportGroupsAsync(ctx, seedDir);
|
await puzzleImporter.ImportGroupsAsync(ctx, seedDir);
|
||||||
await puzzleImporter.ImportPuzzlesAsync(ctx, seedDir);
|
await puzzleImporter.ImportPuzzlesAsync(ctx, seedDir);
|
||||||
|
|||||||
Reference in New Issue
Block a user