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:
@@ -1,24 +1,40 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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.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.
|
||||
/// /leader_skin/* — the leader-skin shop family.
|
||||
/// <list type="bullet">
|
||||
/// <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>
|
||||
[Route("leader_skin")]
|
||||
public class LeaderSkinController : SVSimController
|
||||
{
|
||||
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;
|
||||
_rewards = rewards;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
[HttpPost("set")]
|
||||
@@ -28,8 +44,6 @@ public class LeaderSkinController : SVSimController
|
||||
|
||||
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" });
|
||||
}
|
||||
@@ -44,7 +58,6 @@ public class LeaderSkinController : SVSimController
|
||||
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" });
|
||||
@@ -61,4 +74,336 @@ public class LeaderSkinController : SVSimController
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user