using System.Text.Json; using System.Text.Json.Serialization; using Prospect.Server.Api.Models.Data; using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.CloudScript.Models.Data; using Prospect.Server.Api.Services.UserData; namespace Prospect.Server.Api.Services.CloudScript.Functions; public class FYClaimCompletedActiveContractRewardsRequest { [JsonPropertyName("contractId")] public string ContractID { get; set; } [JsonPropertyName("contractsToUnlock")] public string[] ContractsToUnlock { get; set; } } public class FYClaimCompletedActiveContractRewardsResult { [JsonPropertyName("userId")] public string UserID { get; set; } [JsonPropertyName("error")] public string Error { get; set; } [JsonPropertyName("claimedContractId")] public string ClaimedContractID { get; set; } [JsonPropertyName("newContractIdOnBoard")] public string NewContractIdOnBoard { get; set; } [JsonPropertyName("changedCurrencies")] public FYCurrencyItem[] ChangedCurrencies { get; set; } [JsonPropertyName("itemsGranted")] public List ItemsGranted { get; set; } [JsonPropertyName("itemsUpdatedOrRemoved")] public List ItemsUpdatedOrRemoved { get; set; } [JsonPropertyName("playerFactionProgressData")] public FYPlayerFactionProgressData PlayerFactionProgressData { get; set; } [JsonPropertyName("contractsActivated")] public List ContractsActivated { get; set; } [JsonPropertyName("status")] public int Status { get; set; } // EYClaimContractRewardsStatus [JsonPropertyName("updatedSeasonXp")] public int UpdatedSeasonXp { get; set; } } public class FYGetCompletedContractsResult { [JsonPropertyName("contractsIds")] public List ContractsIDs { get; set; } }; [CloudScriptFunction("ClaimActiveContract")] public class ClaimActiveContract : ICloudScriptFunction { private readonly IHttpContextAccessor _httpContextAccessor; private readonly UserDataService _userDataService; private readonly TitleDataService _titleDataService; private readonly string[] difficulties = ["Easy", "Medium", "Hard"]; private readonly Random rnd = new Random(); public ClaimActiveContract(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService) { _httpContextAccessor = httpContextAccessor; _userDataService = userDataService; _titleDataService = titleDataService; } public async Task ExecuteAsync(FYClaimCompletedActiveContractRewardsRequest request) { var context = _httpContextAccessor.HttpContext; if (context == null) { throw new CloudScriptException("CloudScript was not called within a http request"); } var userId = context.User.FindAuthUserId(); var titleData = _titleDataService.Find(new List{ "Contracts", "Blueprints", "Jobs", "LevelData" }); var contracts = JsonSerializer.Deserialize>(titleData["Contracts"]); if (!contracts.TryGetValue(request.ContractID, out var contract)) { return new FYClaimCompletedActiveContractRewardsResult { UserID = userId, Error = "Contract not found", Status = 13, // EYClaimContractRewardsStatus::WRONG_CONTRACT_ID }; } var factionKey = "FactionProgression" + contract.Faction; // Fortuna pass season XP is stored per season; the client derives the pass level // from this key. No challenge system is emulated, so contracts are the XP source. #if SEASON_3_RELEASE || SEASON_3_DEBUG const string seasonXpKey = "FortunaPass3_SeasonXp"; #else const string seasonXpKey = "FortunaPass2_SeasonXp"; #endif var userData = await _userDataService.FindAsync( userId, userId, new List{"ContractsActive", "ContractsOneTimeCompleted", "Balance", "Inventory", factionKey, "JobBoardsData", seasonXpKey } ); var factionProgression = JsonSerializer.Deserialize(userData[factionKey].Value); var contractsActive = JsonSerializer.Deserialize(userData["ContractsActive"].Value); var contractsCompleted = JsonSerializer.Deserialize(userData["ContractsOneTimeCompleted"].Value); var jobBoardsData = JsonSerializer.Deserialize(userData["JobBoardsData"].Value); var balance = JsonSerializer.Deserialize(userData["Balance"].Value); var inventory = JsonSerializer.Deserialize>(userData["Inventory"].Value); var targetContractIdx = contractsActive.Contracts.FindIndex(item => item.ContractID == contract.Name); if (targetContractIdx == -1) { return new FYClaimCompletedActiveContractRewardsResult { UserID = userId, Error = "Contract not active", Status = 8, // EYClaimContractRewardsStatus::NO_ACTIVE_CONTRACT }; } var blueprints = JsonSerializer.Deserialize>(titleData["Blueprints"]); var targetContract = contractsActive.Contracts[targetContractIdx]; List itemsUpdatedOrRemoved = []; HashSet deletedItemsIds = []; // TODO: Optimize for (var i = 0; i < contract.Objectives.Length; i++) { var objective = contract.Objectives[i]; int remaining = objective.MaxProgress; if (objective.Type == EYContractObjectiveType.OwnNumOfItem) { // NOTE: The backend may perform item ownership validation // since it has the information about the player's items. foreach (var item in inventory) { if (item.BaseItemId != objective.ItemToOwn) { continue; } var localRemaining = remaining; remaining -= item.Amount; item.Amount -= localRemaining; if (item.Amount <= 0) { item.Amount = 0; deletedItemsIds.Add(item.ItemId); } itemsUpdatedOrRemoved.Add(item); if (remaining <= 0) { break; } } } else if (objective.Type.IsRaidRuntime()) { // Raid-runtime objectives (kills, dead-drops, visited areas, looted // containers) can't be tracked in a client-hosted raid — no dedicated // game server reports them, so their progress is never saved. They are // auto-credited on deploy (EnterMatchmakingMatch); accept them here too // (safety net for a contract activated after deploy) so these contracts // remain completable instead of being stuck on "Objective not met". remaining = 0; } else { // Meta objectives (FactionLevel / CompletedMission): validated from the // persisted progress. The objectives are mapped by array index. remaining -= targetContract.Progress[i]; } if (remaining > 0) { return new FYClaimCompletedActiveContractRewardsResult { UserID = userId, Error = "Objective not met", Status = 16, // EYClaimContractRewardsStatus::PROGRESS_MISSING }; } } // TODO: Optimize var newInventory = new List(inventory.Count); foreach (var item in inventory) { if (!deletedItemsIds.Contains(item.ItemId)) { newInventory.Add(item); } } List itemsGranted = new List(); List changedCurrencies = []; foreach (var reward in contract.Rewards) { if (reward.ItemID == "SoftCurrency") { balance.SoftCurrency += reward.Amount; changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency }); } else if (reward.ItemID == "Aurum") { balance.HardCurrency += reward.Amount; changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency }); } else if (reward.ItemID == "InsuranceCurrency") { balance.InsuranceCurrency += reward.Amount; changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "InsuranceCurrency", Amount = balance.InsuranceCurrency }); } else { // TODO: Inventory limit check var blueprintData = blueprints[reward.ItemID]; var remainingAmount = reward.Amount; // Quest rewards are given individually // Populate incomplete stacks first foreach (var item in newInventory) { if (item.BaseItemId != reward.ItemID || item.Amount >= blueprintData.MaxAmountPerStack) { continue; } var remainingSpace = blueprintData.MaxAmountPerStack - item.Amount; var amountToAdd = Math.Min(remainingAmount, remainingSpace); item.Amount += amountToAdd; remainingAmount -= amountToAdd; itemsUpdatedOrRemoved.Add(item); if (remainingAmount == 0) { break; } } while (remainingAmount > 0) { var itemStackAmount = Math.Min(blueprintData.MaxAmountPerStack, remainingAmount); var itemInfo = new FYCustomItemInfo{ ItemId = Guid.NewGuid().ToString(), Amount = itemStackAmount, BaseItemId = reward.ItemID, Durability = blueprintData.DurabilityMax, Insurance = "", InsuranceOwnerPlayfabId = "", ModData = new FYModItems { M = [] }, Origin = new FYItemOriginBackend { G = "", P = "", T = "" }, InsuredAttachmentId = "", PrimaryVanityId = 0, SecondaryVanityId = 0, RolledPerks = [], }; newInventory.Add(itemInfo); itemsGranted.Add(itemInfo); remainingAmount -= itemStackAmount; } } } factionProgression += contract.ReputationIncrease; contractsActive.Contracts.RemoveAt(targetContractIdx); // Main contracts can be completed only once. // Other contracts should be jobs. var newContractIdOnBoard = ""; if (contract.IsMainContract) { contractsCompleted.ContractsIDs.Add(contract.Name); } else { var parts = contract.Name.Split('-'); var difficulty = parts[1]; var levelsData = JsonSerializer.Deserialize>(titleData["LevelData"]); var levels = levelsData[contract.Faction]; var jobs = JsonSerializer.Deserialize>>(titleData["Jobs"]); var level = levels.Length - Array.FindIndex(levels, (int xp) => { return factionProgression >= xp; }); var factionBoard = jobBoardsData.Boards.Find(c => c.FactionId == contract.Faction); var idx = Array.FindIndex(difficulties, d => d == difficulty); var factionJobs = jobs[contract.Faction]; var jobsAvailable = Array.FindAll(factionJobs[difficulty], (string jobId) => { return level >= contracts[jobId].UnlockData.Level; }); // If player completes a job, it means he has at least one unlocked anyway. newContractIdOnBoard = jobsAvailable[rnd.Next(jobsAvailable.Length)]; factionBoard.Contracts[idx].ContractId = newContractIdOnBoard; } // TODO: Check for next unlock in contracts instead of relying on client var newContracts = new List(); foreach (var contractIdToUnlock in request.ContractsToUnlock) { if (contractsCompleted.ContractsIDs.Contains(contractIdToUnlock)) { continue; } var contractToUnlock = contracts[contractIdToUnlock]; var progress = new int[contractToUnlock.Objectives.Length]; // NOTE: Automatically set max progress for player kill objectives. for (var i = 0; i < contractToUnlock.Objectives.Length; i++) { var objective = contractToUnlock.Objectives[i]; if (objective.Type == EYContractObjectiveType.Kills && objective.KillConditions.KillTarget == EYKillTypeAction.Players) { progress[i] = objective.MaxProgress; } } var newContract = new FYActiveContractPlayerData { ContractID = contractIdToUnlock, Progress = progress, }; contractsActive.Contracts.Add(newContract); newContracts.Add(newContract); } // Grant Fortuna pass season XP for completing this contract and persist it, so the // pass level (derived client-side from FortunaPass{season}_SeasonXp) actually moves. // No exact rate ships in the data, so scale it to the contract's reputation value. var seasonXp = int.TryParse(userData[seasonXpKey].Value, out var currentSeasonXp) ? currentSeasonXp : 0; seasonXp += contract.ReputationIncrease * 10; await _userDataService.UpdateAsync( userId, userId, new Dictionary{ ["ContractsOneTimeCompleted"] = JsonSerializer.Serialize(contractsCompleted), ["ContractsActive"] = JsonSerializer.Serialize(contractsActive), ["Balance"] = JsonSerializer.Serialize(balance), ["Inventory"] = JsonSerializer.Serialize(newInventory), [factionKey] = JsonSerializer.Serialize(factionProgression), ["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData), [seasonXpKey] = JsonSerializer.Serialize(seasonXp), } ); return new FYClaimCompletedActiveContractRewardsResult { UserID = userId, Error = "", ClaimedContractID = contract.Name, ContractsActivated = newContracts, ItemsUpdatedOrRemoved = itemsUpdatedOrRemoved, ChangedCurrencies = changedCurrencies.ToArray(), ItemsGranted = itemsGranted, NewContractIdOnBoard = newContractIdOnBoard, PlayerFactionProgressData = new FYPlayerFactionProgressData { FactionID = contract.Faction, CurrentProgression = factionProgression, }, Status = 18, // EYClaimContractRewardsStatus::OK UpdatedSeasonXp = seasonXp // new season XP total (client derives the pass level from it) }; } }