From 3b108d99ca5fbaae2213494d9168c05cadfef7f6 Mon Sep 17 00:00:00 2001 From: neckfire Date: Tue, 14 Jul 2026 12:53:37 +0200 Subject: [PATCH 1/3] feat(store): populate shops via GetStoreItems GetStoreItems was a stub returning an empty object, so every shop (Korolev / ICA / Osiris / QuickShop vendors) showed up empty. Build the store on the fly from the Blueprints title data: for the requested StoreId, list every item that declares an ItemShopsCraftingData entry for that store, converting its currency recipe ingredients into VirtualCurrencyPrices (SoftCurrency->SC, Aurum->AU, InsuranceCurrency->IN). Item-ingredient (crafting) entries are left to the crafting UI. Adds the StoreId/CatalogVersion fields to the request and the FGetStoreItemsResult / FStoreItem PlayFab response models. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/ClientController.cs | 65 ++++++++++++++++++- .../Models/Client/Data/FStoreItem.cs | 39 +++++++++++ .../Models/Client/FGetStoreItems.cs | 15 ++++- .../Models/Client/FGetStoreItemsResult.cs | 25 +++++++ 4 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 src/Prospect.Server.Api/Models/Client/Data/FStoreItem.cs create mode 100644 src/Prospect.Server.Api/Models/Client/FGetStoreItemsResult.cs diff --git a/src/Prospect.Server.Api/Controllers/ClientController.cs b/src/Prospect.Server.Api/Controllers/ClientController.cs index e20e172..a5aee37 100644 --- a/src/Prospect.Server.Api/Controllers/ClientController.cs +++ b/src/Prospect.Server.Api/Controllers/ClientController.cs @@ -155,11 +155,72 @@ public class ClientController : Controller [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] public IActionResult GetStoreItems(FGetStoreItems request) { - return Ok(new ClientResponse + // 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(); + try + { + var titleData = _titleDataService.Find(new List { "Blueprints" }); + var blueprints = JsonSerializer.Deserialize>(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(); + 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(), + DisplayPosition = position++, + }); + } + } + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to build store items for store {StoreId}", storeId); + } + + _logger.LogInformation("GetStoreItems '{StoreId}': {Count} item(s)", storeId, store.Count); + return Ok(new ClientResponse { Code = 200, Status = "OK", - Data = new {} + Data = new FGetStoreItemsResult + { + StoreId = string.IsNullOrEmpty(storeId) ? null : storeId, + CatalogVersion = "StaticItems", + Store = store, + } }); } diff --git a/src/Prospect.Server.Api/Models/Client/Data/FStoreItem.cs b/src/Prospect.Server.Api/Models/Client/Data/FStoreItem.cs new file mode 100644 index 0000000..f00c233 --- /dev/null +++ b/src/Prospect.Server.Api/Models/Client/Data/FStoreItem.cs @@ -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 +{ + /// + /// [optional] Store specific custom data. The data only exists as part of this store; it is not transferred to item instances. + /// + [JsonPropertyName("CustomData")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public object? CustomData { get; set; } + + /// + /// [optional] Intended display position for this item. Note that 0 is the first position and -1 is the last position. + /// + [JsonPropertyName("DisplayPosition")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public uint? DisplayPosition { get; set; } + + /// + /// Unique identifier of the item as it exists in the catalog - note that this must exactly match the ItemId from the catalog. + /// + [JsonPropertyName("ItemId")] + public string ItemId { get; set; } = ""; + + /// + /// [optional] Override prices for this item for specific currencies. + /// + [JsonPropertyName("RealCurrencyPrices")] + public Dictionary RealCurrencyPrices { get; set; } = new(); + + /// + /// [optional] Override prices for this item in virtual currencies and "RM" (the base Real Money purchase price, in USD pennies). + /// + [JsonPropertyName("VirtualCurrencyPrices")] + public Dictionary VirtualCurrencyPrices { get; set; } = new(); +} diff --git a/src/Prospect.Server.Api/Models/Client/FGetStoreItems.cs b/src/Prospect.Server.Api/Models/Client/FGetStoreItems.cs index 659aa1e..f22ed2a 100644 --- a/src/Prospect.Server.Api/Models/Client/FGetStoreItems.cs +++ b/src/Prospect.Server.Api/Models/Client/FGetStoreItems.cs @@ -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 { -} \ No newline at end of file + /// + /// [optional] Catalog version to store items from. Use default catalog version if null. + /// + [JsonPropertyName("CatalogVersion")] + public string? CatalogVersion { get; set; } + + /// + /// Unique identifier for the store which is being requested. + /// + [JsonPropertyName("StoreId")] + public string? StoreId { get; set; } +} diff --git a/src/Prospect.Server.Api/Models/Client/FGetStoreItemsResult.cs b/src/Prospect.Server.Api/Models/Client/FGetStoreItemsResult.cs new file mode 100644 index 0000000..036b7ac --- /dev/null +++ b/src/Prospect.Server.Api/Models/Client/FGetStoreItemsResult.cs @@ -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 +{ + /// + /// [optional] The base catalog that this store is a part of. + /// + [JsonPropertyName("CatalogVersion")] + public string? CatalogVersion { get; set; } + + /// + /// [optional] Array of items which can be purchased from this store. + /// + [JsonPropertyName("Store")] + public List Store { get; set; } = new(); + + /// + /// [optional] The ID of this store. + /// + [JsonPropertyName("StoreId")] + public string? StoreId { get; set; } +} -- 2.54.0 From 40dad82559dcda96fb5d8162a5971e89c6c6aa23 Mon Sep 17 00:00:00 2001 From: neckfire Date: Tue, 14 Jul 2026 13:46:17 +0200 Subject: [PATCH 2/3] feat(store): populate Aurum/cosmetic shops from Vanities GetStoreItems now also serves the cosmetic stores the client queries (AurumShop, and the DailyShop_/WeeklyShop_ rotations) from the Vanities title data: every vanity with StoreData.Amount > 0 is offered for that price in Aurum (AU). AurumShop lists the full priced catalogue (632 items); the daily/weekly ids show a deterministic rotating slice so they stay stable within a period but change across periods. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/ClientController.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/Prospect.Server.Api/Controllers/ClientController.cs b/src/Prospect.Server.Api/Controllers/ClientController.cs index a5aee37..d1fad77 100644 --- a/src/Prospect.Server.Api/Controllers/ClientController.cs +++ b/src/Prospect.Server.Api/Controllers/ClientController.cs @@ -210,6 +210,53 @@ public class ClientController : Controller _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 { "Vanities" }); + var vanities = JsonSerializer.Deserialize>(vanityData["Vanities"]); + var priced = vanities? + .Where(v => v.StoreData != null && v.StoreData.Amount > 0) + .OrderBy(v => v.Name, StringComparer.Ordinal) + .ToList() ?? new List(); + + IEnumerable 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 { ["AU"] = (uint)vanity.StoreData.Amount }, + RealCurrencyPrices = new Dictionary(), + 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 { -- 2.54.0 From 5d93b3541d06cd4dcc0f74a78810fc61a2e792f2 Mon Sep 17 00:00:00 2001 From: neckfire Date: Tue, 14 Jul 2026 13:55:50 +0200 Subject: [PATCH 3/3] fix(store): return per-user owned vanities, not the whole catalogue GetUserInventory returned every vanity in the Vanities title data as an owned item, so the client showed all Aurum-shop cosmetics as already owned. Return only the cosmetics the player actually owns: purchased (VanityItems) plus currently equipped (CharacterVanity slots and the GlobalVanity sprays / banner / emotes / drop pod), so the starter outfit stays owned while the rest of the Aurum shop becomes purchasable. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/ClientController.cs | 70 +++++++++++++++++-- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/src/Prospect.Server.Api/Controllers/ClientController.cs b/src/Prospect.Server.Api/Controllers/ClientController.cs index d1fad77..3c9ca10 100644 --- a/src/Prospect.Server.Api/Controllers/ClientController.cs +++ b/src/Prospect.Server.Api/Controllers/ClientController.cs @@ -352,13 +352,15 @@ public class ClientController : Controller public async Task GetUserInventory(FGetUserInventoryRequest request) { var userId = User.FindAuthUserId(); - var userData = await _userDataService.FindAsync(userId, userId, new List{"Balance", "Inventory", "VanityItems"}); + var userData = await _userDataService.FindAsync(userId, userId, new List{"Balance", "Inventory", "VanityItems", "CharacterVanity", "GlobalVanity"}); var inventory = JsonSerializer.Deserialize(userData["Inventory"].Value); - // TODO: Per-user vanity - // var vanity = JsonSerializer.Deserialize(userData["VanityItems"].Value); - var titleData = _titleDataService.Find(new List{"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{"Blueprints"}); var blueprints = JsonSerializer.Deserialize>(titleData["Blueprints"]); - var vanity = JsonSerializer.Deserialize(titleData["Vanities"]); List items = new List(); foreach (var item in inventory) { if (!blueprints.ContainsKey(item.BaseItemId)) { @@ -384,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 @@ -411,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 CollectOwnedVanityIds(Dictionary userData) + { + var owned = new HashSet(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)] -- 2.54.0