Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cdf8bc7e2 | ||
|
|
5d93b3541d | ||
|
|
40dad82559 | ||
|
|
3b108d99ca |
@@ -155,11 +155,119 @@ public class ClientController : Controller
|
|||||||
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
||||||
public IActionResult GetStoreItems(FGetStoreItems request)
|
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,
|
Code = 200,
|
||||||
Status = "OK",
|
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)
|
public async Task<IActionResult> GetUserInventory(FGetUserInventoryRequest request)
|
||||||
{
|
{
|
||||||
var userId = User.FindAuthUserId();
|
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);
|
var inventory = JsonSerializer.Deserialize<FYCustomItemInfo[]>(userData["Inventory"].Value);
|
||||||
// TODO: Per-user vanity
|
// Per-user vanity ownership: the player owns the cosmetics they've bought (VanityItems)
|
||||||
// var vanity = JsonSerializer.Deserialize<CustomVanityItem[]>(userData["VanityItems"].Value);
|
// plus whatever is currently equipped (CharacterVanity / GlobalVanity), so the starter
|
||||||
var titleData = _titleDataService.Find(new List<string>{"Blueprints", "Vanities"});
|
// 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 blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
|
||||||
var vanity = JsonSerializer.Deserialize<TitleDataVanityInfo[]>(titleData["Vanities"]);
|
|
||||||
List<FItemInstance> items = new List<FItemInstance>();
|
List<FItemInstance> items = new List<FItemInstance>();
|
||||||
foreach (var item in inventory) {
|
foreach (var item in inventory) {
|
||||||
if (!blueprints.ContainsKey(item.BaseItemId)) {
|
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 {
|
items.Add(new FItemInstance {
|
||||||
ItemId = item.Name,
|
ItemId = vanityId,
|
||||||
// ItemInstanceId is not required since it's not a unique item.
|
// ItemInstanceId is not required since it's not a unique item.
|
||||||
ItemClass = "Vanity",
|
ItemClass = "Vanity",
|
||||||
// PurchaseDate = DateTime.Now, // TODO
|
// 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")]
|
[HttpPost("GetTitleData")]
|
||||||
[Produces(MediaTypeNames.Application.Json)]
|
[Produces(MediaTypeNames.Application.Json)]
|
||||||
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
|
[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;
|
using Prospect.Server.Api.Models.Client.Data;
|
||||||
|
|
||||||
namespace Prospect.Server.Api.Models.Client;
|
namespace Prospect.Server.Api.Models.Client;
|
||||||
|
|
||||||
public class FGetStoreItems
|
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