Boutiques : GetStoreItems (vendeurs + Aurum) + fix vanities possédées #2
@@ -155,11 +155,119 @@ public class ClientController : Controller
|
||||
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
||||
public IActionResult GetStoreItems(FGetStoreItems request)
|
||||
{
|
||||
return Ok(new ClientResponse<object>
|
||||
// The vendors (Korolev / ICA / Osiris / QuickShop) and other stores are defined
|
||||
// inside the Blueprints title data: each item lists a per-store entry under
|
||||
// ItemShopsCraftingData keyed by the store id, with the currency price in its
|
||||
// recipe ingredients. This endpoint was previously a stub returning {}, which is
|
||||
// why every shop showed up empty. We build the store on the fly from that data.
|
||||
var storeId = request.StoreId ?? "";
|
||||
var store = new List<FStoreItem>();
|
||||
try
|
||||
{
|
||||
var titleData = _titleDataService.Find(new List<string> { "Blueprints" });
|
||||
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
|
||||
if (blueprints != null && !string.IsNullOrEmpty(storeId))
|
||||
{
|
||||
uint position = 0;
|
||||
foreach (var (itemId, blueprint) in blueprints)
|
||||
{
|
||||
if (blueprint.ItemShopsCraftingData == null) continue;
|
||||
if (!blueprint.ItemShopsCraftingData.TryGetValue(storeId, out var shopData)) continue;
|
||||
if (shopData?.ItemRecipeIngredients == null) continue;
|
||||
|
||||
// Only currency ingredients form a purchasable price. Entries whose
|
||||
// recipe is item-based (crafting) are handled by the crafting UI from
|
||||
// the blueprint data the client already has, not by the buy store.
|
||||
var prices = new Dictionary<string, uint>();
|
||||
foreach (var ingredient in shopData.ItemRecipeIngredients)
|
||||
{
|
||||
var code = ingredient.Currency switch
|
||||
{
|
||||
"SoftCurrency" => "SC",
|
||||
"Aurum" => "AU",
|
||||
"InsuranceCurrency" => "IN",
|
||||
_ => null,
|
||||
};
|
||||
if (code == null || ingredient.Amount < 0) continue;
|
||||
prices[code] = prices.TryGetValue(code, out var current)
|
||||
? current + (uint)ingredient.Amount
|
||||
: (uint)ingredient.Amount;
|
||||
}
|
||||
if (prices.Count == 0) continue;
|
||||
|
||||
store.Add(new FStoreItem
|
||||
{
|
||||
ItemId = itemId,
|
||||
VirtualCurrencyPrices = prices,
|
||||
RealCurrencyPrices = new Dictionary<string, uint>(),
|
||||
DisplayPosition = position++,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(e, "Failed to build store items for store {StoreId}", storeId);
|
||||
}
|
||||
|
||||
// Cosmetic / premium stores are backed by the Vanities title data (not Blueprints):
|
||||
// each vanity carries its price in StoreData.Amount, paid in Aurum. AurumShop lists
|
||||
// the whole priced catalogue; the daily/weekly rotations (ids from RequestStore
|
||||
// RotationData) show a deterministic slice so they change over time but stay stable
|
||||
// within a period.
|
||||
var isAurumShop = storeId == "AurumShop";
|
||||
var isDailyShop = storeId.StartsWith("DailyShop_");
|
||||
var isWeeklyShop = storeId.StartsWith("WeeklyShop_");
|
||||
if (store.Count == 0 && (isAurumShop || isDailyShop || isWeeklyShop))
|
||||
{
|
||||
try
|
||||
{
|
||||
var vanityData = _titleDataService.Find(new List<string> { "Vanities" });
|
||||
var vanities = JsonSerializer.Deserialize<List<TitleDataVanityInfo>>(vanityData["Vanities"]);
|
||||
var priced = vanities?
|
||||
.Where(v => v.StoreData != null && v.StoreData.Amount > 0)
|
||||
.OrderBy(v => v.Name, StringComparer.Ordinal)
|
||||
.ToList() ?? new List<TitleDataVanityInfo>();
|
||||
|
||||
IEnumerable<TitleDataVanityInfo> selection = priced;
|
||||
if ((isDailyShop || isWeeklyShop) && priced.Count > 0)
|
||||
{
|
||||
var size = isWeeklyShop ? 12 : 6;
|
||||
int.TryParse(storeId[(storeId.IndexOf('_') + 1)..], out var rotation);
|
||||
var start = (int)(((long)rotation * size) % priced.Count);
|
||||
selection = Enumerable.Range(0, Math.Min(size, priced.Count))
|
||||
.Select(i => priced[(start + i) % priced.Count]);
|
||||
}
|
||||
|
||||
uint position = 0;
|
||||
foreach (var vanity in selection)
|
||||
{
|
||||
store.Add(new FStoreItem
|
||||
{
|
||||
ItemId = vanity.Name,
|
||||
VirtualCurrencyPrices = new Dictionary<string, uint> { ["AU"] = (uint)vanity.StoreData.Amount },
|
||||
RealCurrencyPrices = new Dictionary<string, uint>(),
|
||||
DisplayPosition = position++,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(e, "Failed to build vanity store {StoreId}", storeId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("GetStoreItems '{StoreId}': {Count} item(s)", storeId, store.Count);
|
||||
return Ok(new ClientResponse<FGetStoreItemsResult>
|
||||
{
|
||||
Code = 200,
|
||||
Status = "OK",
|
||||
Data = new {}
|
||||
Data = new FGetStoreItemsResult
|
||||
{
|
||||
StoreId = string.IsNullOrEmpty(storeId) ? null : storeId,
|
||||
CatalogVersion = "StaticItems",
|
||||
Store = store,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,13 +352,15 @@ public class ClientController : Controller
|
||||
public async Task<IActionResult> GetUserInventory(FGetUserInventoryRequest request)
|
||||
{
|
||||
var userId = User.FindAuthUserId();
|
||||
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems"});
|
||||
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems", "CharacterVanity", "GlobalVanity"});
|
||||
var inventory = JsonSerializer.Deserialize<FYCustomItemInfo[]>(userData["Inventory"].Value);
|
||||
// TODO: Per-user vanity
|
||||
// var vanity = JsonSerializer.Deserialize<CustomVanityItem[]>(userData["VanityItems"].Value);
|
||||
var titleData = _titleDataService.Find(new List<string>{"Blueprints", "Vanities"});
|
||||
// Per-user vanity ownership: the player owns the cosmetics they've bought (VanityItems)
|
||||
// plus whatever is currently equipped (CharacterVanity / GlobalVanity), so the starter
|
||||
// outfit stays owned. Previously the whole Vanities catalogue was returned as owned,
|
||||
// which made every item in the Aurum shop show up as already "owned".
|
||||
var ownedVanityIds = CollectOwnedVanityIds(userData);
|
||||
var titleData = _titleDataService.Find(new List<string>{"Blueprints"});
|
||||
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
|
||||
var vanity = JsonSerializer.Deserialize<TitleDataVanityInfo[]>(titleData["Vanities"]);
|
||||
List<FItemInstance> items = new List<FItemInstance>();
|
||||
foreach (var item in inventory) {
|
||||
if (!blueprints.ContainsKey(item.BaseItemId)) {
|
||||
@@ -276,9 +386,9 @@ public class ClientController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var item in vanity) {
|
||||
foreach (var vanityId in ownedVanityIds) {
|
||||
items.Add(new FItemInstance {
|
||||
ItemId = item.Name,
|
||||
ItemId = vanityId,
|
||||
// ItemInstanceId is not required since it's not a unique item.
|
||||
ItemClass = "Vanity",
|
||||
// PurchaseDate = DateTime.Now, // TODO
|
||||
@@ -303,6 +413,60 @@ public class ClientController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
// Collects the vanity ids a player actually owns: everything they've purchased
|
||||
// (VanityItems) plus everything currently equipped (CharacterVanity slots and the
|
||||
// GlobalVanity sprays / banner / emotes / drop pod). Parsed defensively with
|
||||
// JsonDocument so malformed / partial data never breaks the inventory response.
|
||||
private static HashSet<string> CollectOwnedVanityIds(Dictionary<string, FUserDataRecord> userData)
|
||||
{
|
||||
var owned = new HashSet<string>(StringComparer.Ordinal);
|
||||
void AddId(string? id) { if (!string.IsNullOrEmpty(id)) owned.Add(id); }
|
||||
|
||||
if (userData.TryGetValue("VanityItems", out var viRec) && !string.IsNullOrWhiteSpace(viRec.Value))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(viRec.Value);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("baseItemId", out var b))
|
||||
AddId(b.GetString());
|
||||
}
|
||||
catch { /* ignore malformed VanityItems */ }
|
||||
}
|
||||
|
||||
if (userData.TryGetValue("CharacterVanity", out var cvRec) && !string.IsNullOrWhiteSpace(cvRec.Value))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(cvRec.Value);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
if (prop.Value.ValueKind == JsonValueKind.Object && prop.Value.TryGetProperty("id", out var id))
|
||||
AddId(id.GetString());
|
||||
}
|
||||
catch { /* ignore malformed CharacterVanity */ }
|
||||
}
|
||||
|
||||
if (userData.TryGetValue("GlobalVanity", out var gvRec) && !string.IsNullOrWhiteSpace(gvRec.Value))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(gvRec.Value);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (root.TryGetProperty("activeGlobalVanityIds", out var arr) && arr.ValueKind == JsonValueKind.Array)
|
||||
foreach (var el in arr.EnumerateArray()) AddId(el.GetString());
|
||||
if (root.TryGetProperty("droppodId", out var dp)) AddId(dp.GetString());
|
||||
}
|
||||
}
|
||||
catch { /* ignore malformed GlobalVanity */ }
|
||||
}
|
||||
|
||||
return owned;
|
||||
}
|
||||
|
||||
[HttpPost("GetTitleData")]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Prospect.Server.Api.Models.Client.Data;
|
||||
|
||||
// PlayFab StoreItem: a single catalog item offered for sale in a store, with its prices.
|
||||
public class FStoreItem
|
||||
{
|
||||
/// <summary>
|
||||
/// [optional] Store specific custom data. The data only exists as part of this store; it is not transferred to item instances.
|
||||
/// </summary>
|
||||
[JsonPropertyName("CustomData")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public object? CustomData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// [optional] Intended display position for this item. Note that 0 is the first position and -1 is the last position.
|
||||
/// </summary>
|
||||
[JsonPropertyName("DisplayPosition")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public uint? DisplayPosition { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier of the item as it exists in the catalog - note that this must exactly match the ItemId from the catalog.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ItemId")]
|
||||
public string ItemId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// [optional] Override prices for this item for specific currencies.
|
||||
/// </summary>
|
||||
[JsonPropertyName("RealCurrencyPrices")]
|
||||
public Dictionary<string, uint> RealCurrencyPrices { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// [optional] Override prices for this item in virtual currencies and "RM" (the base Real Money purchase price, in USD pennies).
|
||||
/// </summary>
|
||||
[JsonPropertyName("VirtualCurrencyPrices")]
|
||||
public Dictionary<string, uint> VirtualCurrencyPrices { get; set; } = new();
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
using Prospect.Server.Api.Models.Client.Data;
|
||||
|
||||
namespace Prospect.Server.Api.Models.Client;
|
||||
|
||||
public class FGetStoreItems
|
||||
{
|
||||
}
|
||||
/// <summary>
|
||||
/// [optional] Catalog version to store items from. Use default catalog version if null.
|
||||
/// </summary>
|
||||
[JsonPropertyName("CatalogVersion")]
|
||||
public string? CatalogVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for the store which is being requested.
|
||||
/// </summary>
|
||||
[JsonPropertyName("StoreId")]
|
||||
public string? StoreId { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Prospect.Server.Api.Models.Client.Data;
|
||||
|
||||
namespace Prospect.Server.Api.Models.Client;
|
||||
|
||||
public class FGetStoreItemsResult
|
||||
{
|
||||
/// <summary>
|
||||
/// [optional] The base catalog that this store is a part of.
|
||||
/// </summary>
|
||||
[JsonPropertyName("CatalogVersion")]
|
||||
public string? CatalogVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// [optional] Array of items which can be purchased from this store.
|
||||
/// </summary>
|
||||
[JsonPropertyName("Store")]
|
||||
public List<FStoreItem> Store { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// [optional] The ID of this store.
|
||||
/// </summary>
|
||||
[JsonPropertyName("StoreId")]
|
||||
public string? StoreId { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user