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

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.