using System.Net.Mime; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Prospect.Server.Api.Config; using Prospect.Server.Api.Models.Client; using Prospect.Server.Api.Models.Client.Data; using Prospect.Server.Api.Models.Data; using Prospect.Server.Api.Services.Auth; using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.Auth.User; using Prospect.Server.Api.Services.Database; using Prospect.Server.Api.Services.Database.Models; using Prospect.Server.Api.Services.UserData; using Prospect.Steam; namespace Prospect.Server.Api.Controllers; [ApiController] [Route("Client")] public class ClientController : Controller { private const int AppIdDefault = 480; private const int AppIdCycleBeta = 1600361; private const int AppIdCycle = 868270; private readonly ILogger _logger; private readonly PlayFabSettings _settings; private readonly AuthTokenService _authTokenService; private readonly DbUserService _userService; private readonly DbEntityService _entityService; private readonly UserDataService _userDataService; private readonly TitleDataService _titleDataService; private readonly Prospect.Server.Api.Services.Steam.SteamWebApiService _steamWebApi; public ClientController(ILogger logger, IOptions settings, AuthTokenService authTokenService, DbUserService userService, DbEntityService entityService, UserDataService userDataService, TitleDataService titleDataService, Prospect.Server.Api.Services.Steam.SteamWebApiService steamWebApi) { _settings = settings.Value; _logger = logger; _authTokenService = authTokenService; _userService = userService; _entityService = entityService; _userDataService = userDataService; _titleDataService = titleDataService; _steamWebApi = steamWebApi; } [AllowAnonymous] [HttpPost("LoginWithSteam")] [Produces(MediaTypeNames.Application.Json)] public async Task LoginWithSteam(FLoginWithSteamRequest request) { if (!string.IsNullOrEmpty(request.SteamTicket)) { var ticket = AppTicket.Parse(request.SteamTicket); if (ticket.IsValid && ticket.HasValidSignature && (ticket.AppId == AppIdDefault || ticket.AppId == AppIdCycleBeta || ticket.AppId == AppIdCycle)) { var userSteamId = ticket.SteamId.ToString(); var user = await _userService.FindOrCreateAsync(PlayFabUserAuthType.Steam, userSteamId); // Backfill the display name from the Steam persona when we don't have a real // one yet (accounts are created as "Unknown"; the client never sends a name). if (string.IsNullOrEmpty(user.DisplayName) || user.DisplayName == "Unknown") { var persona = await _steamWebApi.GetPersonaNameAsync(userSteamId); if (!string.IsNullOrWhiteSpace(persona)) { await _userService.UpdateDisplayNameAsync(user.Id, persona); user.DisplayName = persona; } } var entity = await _entityService.FindOrCreateAsync(user.Id); var userTicket = _authTokenService.GenerateUser(entity); var entityTicket = _authTokenService.GenerateEntity(entity); await _userDataService.InitAsync(user.Id); return Ok(new ClientResponse { Code = 200, Status = "OK", Data = new FServerLoginResult { EntityToken = new FEntityTokenResponse { Entity = new FEntityKey { Id = entity.Id, Type = "title_player_account", TypeString = "title_player_account" }, EntityToken = entityTicket, TokenExpiration = DateTime.UtcNow.AddDays(6), // TODO: }, InfoResultPayload = new FGetPlayerCombinedInfoResultPayload { CharacterInventories = new List(), PlayerProfile = new FPlayerProfileModel { DisplayName = user.DisplayName, PlayerId = user.Id, PublisherId = _settings.PublisherId, TitleId = _settings.TitleId }, UserDataVersion = 0, UserInventory = new List(), UserReadOnlyDataVersion = 0 }, LastLoginTime = DateTime.UtcNow, // TODO: NewlyCreated = false, // TODO: PlayFabId = user.Id, SessionTicket = userTicket, SettingsForUser = new FUserSettings { GatherDeviceInfo = true, GatherFocusInfo = true, NeedsAttribution = false, }, TreatmentAssignment = new FTreatmentAssignment { Variables = new List(), Variants = new List() } } }); } _logger.LogWarning("Invalid steam ticket specified, IsExpired {IsExpired}, IsValid {IsValid}, HasValidSignature {Sig}, AppId {AppId}", ticket.IsExpired, ticket.IsValid, ticket.HasValidSignature, ticket.AppId); } return BadRequest(new ClientResponse { Code = 400, Status = "BadRequest", Error = "InvalidSteamTicket", ErrorCode = 1010, ErrorMessage = "Steam API AuthenticateUserTicket error response .." }); } [HttpPost("AddGenericID")] [Produces(MediaTypeNames.Application.Json)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] public async Task AddGenericId(FAddGenericIDRequest request) { // Persist the linked generic service id instead of dropping it (was a no-op stub). var userId = User.FindAuthUserId(); if (request.GenericId != null && !string.IsNullOrEmpty(request.GenericId.ServiceName)) { await _userDataService.UpdateAsync(userId, userId, new Dictionary { ["GenericId_" + request.GenericId.ServiceName] = request.GenericId.UserId ?? "" }); } return Ok(new ClientResponse { Code = 200, Status = "OK", Data = new {} }); } [HttpPost("GetStoreItems")] [Produces(MediaTypeNames.Application.Json)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] public IActionResult GetStoreItems(FGetStoreItems request) { // 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); } // 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 { Code = 200, Status = "OK", Data = new FGetStoreItemsResult { StoreId = string.IsNullOrEmpty(storeId) ? null : storeId, CatalogVersion = "StaticItems", Store = store, } }); } [HttpPost("UpdateUserTitleDisplayName")] [Produces(MediaTypeNames.Application.Json)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] public async Task UpdateUserTitleDisplayName(FUpdateUserTitleDisplayNameRequest request) { // Persist the rename, otherwise it is echoed back but lost on next login. var userId = User.FindAuthUserId(); await _userService.UpdateDisplayNameAsync(userId, request.DisplayName); return Ok(new ClientResponse { Code = 200, Status = "OK", Data = new FUpdateUserTitleDisplayNameResult { DisplayName = request.DisplayName } }); } [HttpPost("UpdateUserData")] [Produces(MediaTypeNames.Application.Json)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] public async Task UpdateUserData(FUpdateUserDataRequest request) { var userId = User.FindAuthUserId(); await _userDataService.UpdateAsync(userId, request.PlayFabId, request.Data); return Ok(new ClientResponse { Code = 200, Status = "OK", Data = new FUpdateUserDataResult { DataVersion = 0 } }); } [HttpPost("GetUserData")] [Produces(MediaTypeNames.Application.Json)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] public async Task GetUserData(FGetUserDataRequest request) { var userId = User.FindAuthUserId(); var userData = await _userDataService.FindAsync(userId, request.PlayFabId, request.Keys); return Ok(new ClientResponse { Code = 200, Status = "OK", Data = new FGetUserDataResult { Data = userData, DataVersion = 0 } }); } [HttpPost("GetUserReadOnlyData")] [Produces(MediaTypeNames.Application.Json)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] public async Task GetUserReadOnlyData(FGetUserDataRequest request) { var userId = User.FindAuthUserId(); var userData = await _userDataService.FindAsync(userId, request.PlayFabId, request.Keys); return Ok(new ClientResponse { Code = 200, Status = "OK", Data = new FGetUserDataResult { Data = userData, DataVersion = 0 } }); } [HttpPost("GetUserInventory")] [Produces(MediaTypeNames.Application.Json)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] public async Task GetUserInventory(FGetUserInventoryRequest request) { var userId = User.FindAuthUserId(); var userData = await _userDataService.FindAsync(userId, userId, new List{"Balance", "Inventory", "VanityItems", "CharacterVanity", "GlobalVanity"}); var inventory = JsonSerializer.Deserialize(userData["Inventory"].Value); // 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"]); List items = new List(); foreach (var item in inventory) { if (!blueprints.ContainsKey(item.BaseItemId)) { _logger.LogWarning("Failed to find item with ID {ItemID}", item.BaseItemId); continue; } var blueprintData = blueprints[item.BaseItemId]; items.Add(new FItemInstance { ItemId = item.BaseItemId, ItemInstanceId = item.ItemId, ItemClass = blueprintData.Kind, // PurchaseDate = DateTime.Now, // TODO // UnitPrice = 0, CatalogVersion = "StaticItems", CustomData = new Dictionary { ["mods"] = JsonSerializer.Serialize(item.ModData), // ["insurance"] = "None", // TODO ["vanity"] = $"{{\"p\":{item.PrimaryVanityId},\"s\":{item.SecondaryVanityId}}}", // p - primary, s - secondary ["coreData"] = $"{{\"a\":{item.Amount},\"d\":{item.Durability}}}", // a - amount, d - durability ["origin"] = JsonSerializer.Serialize(item.Origin), ["rolledPerks"] = JsonSerializer.Serialize(item.RolledPerks), } }); } foreach (var vanityId in ownedVanityIds) { items.Add(new FItemInstance { ItemId = vanityId, // ItemInstanceId is not required since it's not a unique item. ItemClass = "Vanity", // PurchaseDate = DateTime.Now, // TODO CatalogVersion = "StaticItems", CustomData = new Dictionary { ["coreData"] = "{}", // Vanity items don't seem to have any coreData. But it's still required since it's an inventory item. ["origin"] = "{}", } }); } return Ok(new ClientResponse { Code = 200, Status = "OK", Data = new FGetUserInventoryResult { Inventory = items, VirtualCurrency = JsonSerializer.Deserialize(userData["Balance"].Value), VirtualCurrencyRechargeTimes = new Dictionary() } }); } // 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)] public IActionResult GetTitleData(FGetTitleDataRequest request) { return Ok(new ClientResponse { Code = 200, Status = "OK", Data = new FGetTitleDataResult() { Data = _titleDataService.Find(request.Keys) } }); } }