diff --git a/Libraries/Core/Helpers/ItemHelper.cs b/Libraries/Core/Helpers/ItemHelper.cs index d4a086dc..947987d1 100644 --- a/Libraries/Core/Helpers/ItemHelper.cs +++ b/Libraries/Core/Helpers/ItemHelper.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; using SptCommon.Annotations; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; +using Core.Models.Eft.ItemEvent; using Core.Models.Enums; using Core.Models.Utils; using Core.Services; @@ -2013,6 +2014,19 @@ public class ItemHelper( } } } + + public Product GetProductFromItem(Item item) + { + return new Product + { + Id = item.Id, + Template = item.Template, + ParentId = item.ParentId, + SlotId = item.SlotId, + Location = item.Location, + Upd = item.Upd, + }; + } } public class ItemSize diff --git a/Libraries/Core/Models/Eft/Common/Tables/BotBase.cs b/Libraries/Core/Models/Eft/Common/Tables/BotBase.cs index 9c3856d1..63604594 100644 --- a/Libraries/Core/Models/Eft/Common/Tables/BotBase.cs +++ b/Libraries/Core/Models/Eft/Common/Tables/BotBase.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Core.Models.Eft.ItemEvent; using Core.Models.Eft.Ragfair; using Core.Models.Enums; using Core.Utils.Json; @@ -620,18 +621,6 @@ public record Production // use this instead of productive and scavcase public string? RecipeId { get; set; } } -public record Product -{ - [JsonPropertyName("_id")] - public string? Id { get; set; } - - [JsonPropertyName("_tpl")] - public string? Template { get; set; } - - [JsonPropertyName("upd")] - public Upd? Upd { get; set; } -} - public record BotHideoutArea { [JsonPropertyName("type")] diff --git a/Libraries/Core/Models/Eft/ItemEvent/ItemEventRouterBase.cs b/Libraries/Core/Models/Eft/ItemEvent/ItemEventRouterBase.cs index b8ff7d7b..30b01a06 100644 --- a/Libraries/Core/Models/Eft/ItemEvent/ItemEventRouterBase.cs +++ b/Libraries/Core/Models/Eft/ItemEvent/ItemEventRouterBase.cs @@ -182,7 +182,7 @@ public record Product public string? SlotId { get; set; } [JsonPropertyName("location")] - public ItemLocation? Location { get; set; } + public object? Location { get; set; } [JsonPropertyName("upd")] public Upd? Upd { get; set; } diff --git a/Libraries/Core/Services/PaymentService.cs b/Libraries/Core/Services/PaymentService.cs index a5d83de9..19752254 100644 --- a/Libraries/Core/Services/PaymentService.cs +++ b/Libraries/Core/Services/PaymentService.cs @@ -1,14 +1,224 @@ -using SptCommon.Annotations; +using Core.Helpers; +using SptCommon.Annotations; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; +using Core.Models.Eft.Inventory; using Core.Models.Eft.ItemEvent; using Core.Models.Eft.Trade; +using Core.Models.Enums; +using Core.Models.Spt.Config; +using Core.Models.Utils; +using Core.Servers; +using Core.Utils; +using Product = Core.Models.Eft.ItemEvent.Product; namespace Core.Services; [Injectable(InjectionType.Singleton)] -public class PaymentService +public class PaymentService( + ISptLogger _logger, + HashUtil _hashUtil, + HttpResponseUtil _httpResponseUtil, + DatabaseService _databaseService, + HandbookHelper _handbookHelper, + TraderHelper _traderHelper, + ItemHelper _itemHelper, + InventoryHelper _inventoryHelper, + LocalisationService _localisationService, + PaymentHelper _paymentHelper, + ConfigServer _configServer +) { + protected InventoryConfig _inventoryConfig = _configServer.GetConfig(); + + /** + * Take money and insert items into return to server request + * @param pmcData Pmc profile + * @param request Buy item request + * @param sessionID Session id + * @param output Client response + */ + public void PayMoney(PmcData pmcData, ProcessBuyTradeRequestData request, string sessionID, ItemEventRouterResponse output) + { + // May need to convert to trader currency + var trader = _traderHelper.GetTrader(request.TransactionId, sessionID); + var payToTrader = _traderHelper.TraderEnumHasValue(request.TransactionId); + + // Track the amounts of each type of currency involved in the trade. + Dictionary currencyAmounts = new Dictionary(); + + // Delete barter items and track currencies + foreach (var itemRequest in request.SchemeItems) { + // Find the corresponding item in the player's inventory. + var item = pmcData.Inventory.Items.FirstOrDefault((i) => i.Id == itemRequest.Id); + if (item is not null) { + if (!_paymentHelper.IsMoneyTpl(item.Template)) { + // If the item is not money, remove it from the inventory. + _inventoryHelper.RemoveItemByCount( + pmcData, + item.Id, + (int)itemRequest.Count, + sessionID, + output + ); + itemRequest.Count = 0; + } else { + // If the item is money, add its count to the currencyAmounts object. + currencyAmounts.TryAdd(item.Template, (currencyAmounts[item.Template] ?? 0) + itemRequest.Count); + } + } else { + // Used by `SptInsure` + // Handle differently, `id` is the money type tpl + var currencyTpl = itemRequest.Id; + currencyAmounts.TryAdd(currencyTpl, (currencyAmounts[currencyTpl] ?? 0) + itemRequest.Count); + } + } + + // Track the total amount of all currencies. + var totalCurrencyAmount = 0; + + // Loop through each type of currency involved in the trade. + foreach (var currencyTpl in currencyAmounts) { + var currencyAmount = currencyTpl.Value; + totalCurrencyAmount += (int)currencyAmount; + + if (currencyAmount > 0) { + // Find money stacks in inventory and remove amount needed + update output object to inform client of changes + AddPaymentToOutput(pmcData, currencyTpl.Key, (int)currencyAmount, sessionID, output); + + // If there are warnings, exit early. + if (output.Warnings.Count > 0) { + return; + } + + if (payToTrader) { + // Convert the amount to the trader's currency and update the sales sum. + var costOfPurchaseInCurrency = _handbookHelper.FromRUB( + _handbookHelper.InRUB(currencyAmount ?? 0, currencyTpl.Key), + _paymentHelper.GetCurrency(trader.Currency) + ); + + // Only update traders + pmcData.TradersInfo[request.TransactionId].SalesSum += costOfPurchaseInCurrency; + } + } + } + + // If no currency-based payment is involved, handle it separately + if (totalCurrencyAmount == 0 && payToTrader) { + _logger.Debug(_localisationService.GetText("payment-zero_price_no_payment")); + + // Convert the handbook price to the trader's currency and update the sales sum. + var costOfPurchaseInCurrency = _handbookHelper.FromRUB( + GetTraderItemHandbookPriceRouble(request.ItemId, request.TransactionId) ?? 0, + _paymentHelper.GetCurrency(trader.Currency) + ); + + pmcData.TradersInfo[request.TransactionId].SalesSum += costOfPurchaseInCurrency; + } + + if (payToTrader) { + _traderHelper.LevelUp(request.TransactionId, pmcData); + } + + _logger.Debug("Item(s) taken. Status OK."); + } + + private double? GetTraderItemHandbookPriceRouble(string? traderAssortId, string traderId) + { + var purchasedAssortItem = _traderHelper.GetTraderAssortItemByAssortId(traderId, traderAssortId); + if (purchasedAssortItem is null) { + return 1; + } + + var assortItemPriceRouble = _handbookHelper.GetTemplatePrice(purchasedAssortItem.Template); + if (assortItemPriceRouble is null) { + _logger.Debug($"No item price found for {purchasedAssortItem.Template} on trader: {traderId} in assort: {traderAssortId}"); + + return 1; + } + + return assortItemPriceRouble; + } + + public void GiveProfileMoney(PmcData pmcData, double? amountToSend, ProcessSellTradeRequestData request, + ItemEventRouterResponse output, string sessionID) + { + var trader = _traderHelper.GetTrader(request.TransactionId, sessionID); + if (trader is null) { + _logger.Error($"Unable to add currency to profile as trader: {request.TransactionId} does not exist"); + + return; + } + + var currencyTpl = _paymentHelper.GetCurrency(trader.Currency); + var calcAmount = _handbookHelper.FromRUB(_handbookHelper.InRUB(amountToSend ?? 0, currencyTpl), currencyTpl); + var currencyMaxStackSize = _itemHelper.GetItem(currencyTpl).Value.Properties?.StackMaxSize; + if (currencyMaxStackSize is null) { + _logger.Error($"Unable to add currency: {currencyTpl} to profile as it lacks a _props property"); + + return; + } + var skipSendingMoneyToStash = false; + + foreach (var item in pmcData.Inventory.Items) { + // Item is not currency + if (item.Template != currencyTpl) { + continue; + } + + // Item is not in the stash + if (!_inventoryHelper.IsItemInStash(pmcData, item)) { + continue; + } + + // Found currency item + if (item.Upd.StackObjectsCount < currencyMaxStackSize) { + if (item.Upd.StackObjectsCount + calcAmount > currencyMaxStackSize) { + // calculate difference + calcAmount -= (currencyMaxStackSize - item.Upd.StackObjectsCount) ?? 0; + item.Upd.StackObjectsCount = currencyMaxStackSize; + } else { + skipSendingMoneyToStash = true; + item.Upd.StackObjectsCount += calcAmount; + } + + // Inform client of change to items StackObjectsCount + output.ProfileChanges[sessionID].Items.ChangedItems.Add(_itemHelper.GetProductFromItem(item)); + + if (skipSendingMoneyToStash) { + break; + } + } + } + + // Create single currency item with all currency on it + Item rootCurrencyReward = new Item { + Id = _hashUtil.Generate(), + Template = currencyTpl, + Upd = new Upd { StackObjectsCount = Math.Round(calcAmount) }, + }; + + // Ensure money is properly split to follow its max stack size limit + var rewards = _itemHelper.SplitStackIntoSeparateItems(rootCurrencyReward); + + if (!skipSendingMoneyToStash) { + AddItemsDirectRequest addItemToStashRequest = new AddItemsDirectRequest { + ItemsWithModsToAdd = rewards, + FoundInRaid = false, + Callback = null, + UseSortingTable = true, + }; + _inventoryHelper.AddItemsToStash(sessionID, addItemToStashRequest, pmcData, output); + } + + // Calcualte new total sale sum with trader item sold to + var saleSum = pmcData.TradersInfo[request.TransactionId].SalesSum + amountToSend; + + pmcData.TradersInfo[request.TransactionId].SalesSum = saleSum; + _traderHelper.LevelUp(request.TransactionId, pmcData); + } + /** * Remove currency from player stash/inventory and update client object with changes * @param pmcData Player profile to find and remove currency from @@ -20,12 +230,59 @@ public class PaymentService public void AddPaymentToOutput( PmcData pmcData, string currencyTpl, - decimal amountToPay, + double amountToPay, string sessionID, ItemEventRouterResponse output ) { - throw new NotImplementedException(); + var moneyItemsInInventory = GetSortedMoneyItemsInInventory( + pmcData, + currencyTpl, + pmcData.Inventory.Stash + ); + + //Ensure all money items found have a upd + foreach (var moneyStack in moneyItemsInInventory) { + moneyStack.Upd ??= new Upd { StackObjectsCount = 1 }; + } + + var amountAvailable = moneyItemsInInventory.Aggregate(0, + (accumulator, item) => (int)(accumulator + item.Upd.StackObjectsCount) + ); + + // If no money in inventory or amount is not enough we return false + if (moneyItemsInInventory.Count <= 0 || amountAvailable < amountToPay) { + _logger.Error( + _localisationService.GetText("payment-not_enough_money_to_complete_transation", new { + amountToPay = amountToPay, + amountAvailable = amountAvailable, + }) + ); + _httpResponseUtil.AppendErrorToOutput( + output, + _localisationService.GetText("payment-not_enough_money_to_complete_transation_short", amountToPay), + BackendErrorCodes.UnknownTradingError + ); + + return; + } + + var leftToPay = amountToPay; + foreach (var profileMoneyItem in moneyItemsInInventory) { + var itemAmount = profileMoneyItem.Upd.StackObjectsCount; + if (leftToPay >= itemAmount) { + leftToPay -= itemAmount ?? 0; + _inventoryHelper.RemoveItem(pmcData, profileMoneyItem.Id, sessionID, output); + } else { + profileMoneyItem.Upd.StackObjectsCount -= leftToPay; + leftToPay = 0; + output.ProfileChanges[sessionID].Items.ChangedItems.Add(_itemHelper.GetProductFromItem(profileMoneyItem)); + } + + if (leftToPay == 0) { + break; + } + } } /** @@ -38,7 +295,15 @@ public class PaymentService */ protected List GetSortedMoneyItemsInInventory(PmcData pmcData, string currencyTpl, string playerStashId) { - throw new NotImplementedException(); + var moneyItemsInInventory = _itemHelper.FindBarterItems("tpl", pmcData.Inventory.Items, currencyTpl); + if (moneyItemsInInventory.Count == 0) { + _logger.Debug($"No {currencyTpl} money items found in inventory"); + } + + // Prioritise items in stash to top of array + moneyItemsInInventory.Sort((a, b) => PrioritiseStashSort(a, b, pmcData.Inventory.Items, playerStashId)); + + return moneyItemsInInventory; } /** @@ -52,7 +317,59 @@ public class PaymentService */ protected int PrioritiseStashSort(Item a, Item b, List inventoryItems, string playerStashId) { - throw new NotImplementedException(); + // a in root of stash, prioritise + if (a.ParentId == playerStashId && b.ParentId != playerStashId) { + return -1; + } + + // b in root stash, prioritise + if (a.ParentId != playerStashId && b.ParentId == playerStashId) { + return 1; + } + + // both in containers + if (a.SlotId == "main" && b.SlotId == "main") { + // Both items are in containers + var aInStash = this.IsInStash(a.ParentId, inventoryItems, playerStashId); + var bInStash = this.IsInStash(b.ParentId, inventoryItems, playerStashId); + + // a in stash in container, prioritise + if (aInStash && !bInStash) { + return -1; + } + + // b in stash in container, prioritise + if (!aInStash && bInStash) { + return 1; + } + + // Both in stash in containers + if (aInStash && bInStash) { + // Containers where taking money from would inconvinence player + var deprioritisedContainers = _inventoryConfig.DeprioritisedMoneyContainers; + var aImmediateParent = inventoryItems.FirstOrDefault((item) => item.Id == a.ParentId); + var bImmediateParent = inventoryItems.FirstOrDefault((item) => item.Id == b.ParentId); + + // A is not a deprioritised container, B is + if ( + !deprioritisedContainers.Contains(aImmediateParent.Template) && + deprioritisedContainers.Contains(bImmediateParent.Template) + ) { + return -1; + } + + // B is not a deprioritised container, A is + if ( + deprioritisedContainers.Contains(aImmediateParent.Template) && + !deprioritisedContainers.Contains(bImmediateParent.Template) + ) { + return 1; + } + } + } + + // they match + return 0; } /** @@ -64,23 +381,20 @@ public class PaymentService */ protected bool IsInStash(string itemId, List inventoryItems, string playerStashId) { - throw new NotImplementedException(); - } + var itemParent = inventoryItems.FirstOrDefault((item) => item.Id == itemId); - /** - * Take money and insert items into return to server request - * @param pmcData Pmc profile - * @param request Buy item request - * @param sessionID Session id - * @param output Client response - */ - public void PayMoney(PmcData pmcData, ProcessBuyTradeRequestData request, string sessionID, ItemEventRouterResponse output) - { - throw new NotImplementedException(); - } + if (itemParent is not null) { + if (itemParent.SlotId == "hideout") { + return true; + } - public void GiveProfileMoney(PmcData profileToReceiveMoney, double? sellRequestPrice, ProcessSellTradeRequestData sellRequest, ItemEventRouterResponse output, string sessionId) - { - throw new NotImplementedException(); + if (itemParent.Id == playerStashId) { + return true; + } + + return IsInStash(itemParent.ParentId, inventoryItems, playerStashId); + } + + return false; } }