631 lines
22 KiB
C#
631 lines
22 KiB
C#
using SPTarkov.DI.Annotations;
|
|
using SPTarkov.Server.Core.Helpers;
|
|
using SPTarkov.Server.Core.Models.Eft.Common;
|
|
using SPTarkov.Server.Core.Models.Eft.Common.Tables;
|
|
using SPTarkov.Server.Core.Models.Eft.Inventory;
|
|
using SPTarkov.Server.Core.Models.Eft.ItemEvent;
|
|
using SPTarkov.Server.Core.Models.Eft.Trade;
|
|
using SPTarkov.Server.Core.Models.Enums;
|
|
using SPTarkov.Server.Core.Models.Spt.Config;
|
|
using SPTarkov.Server.Core.Models.Utils;
|
|
using SPTarkov.Server.Core.Servers;
|
|
using SPTarkov.Server.Core.Utils;
|
|
using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel;
|
|
|
|
namespace SPTarkov.Server.Core.Services;
|
|
|
|
[Injectable(InjectionType.Singleton)]
|
|
public class PaymentService(
|
|
ISptLogger<PaymentService> _logger,
|
|
HashUtil _hashUtil,
|
|
HttpResponseUtil _httpResponseUtil,
|
|
HandbookHelper _handbookHelper,
|
|
TraderHelper _traderHelper,
|
|
ItemHelper _itemHelper,
|
|
InventoryHelper _inventoryHelper,
|
|
LocalisationService _localisationService,
|
|
PaymentHelper _paymentHelper,
|
|
ConfigServer _configServer
|
|
)
|
|
{
|
|
protected InventoryConfig _inventoryConfig = _configServer.GetConfig<InventoryConfig>();
|
|
|
|
/// <summary>
|
|
/// Take money and insert items into return to server request
|
|
/// </summary>
|
|
/// <param name="pmcData"> PMC Profile </param>
|
|
/// <param name="request"> Buy item request </param>
|
|
/// <param name="sessionID"> Session ID </param>
|
|
/// <param name="output"> Client response </param>
|
|
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.TraderExists(request.TransactionId);
|
|
|
|
// Track the amounts of each type of currency involved in the trade.
|
|
var currencyAmounts = new Dictionary<string, double?>();
|
|
|
|
// 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.
|
|
// sometimes the currency can be in two parts, so it fails to tryadd the second part
|
|
if (!currencyAmounts.TryAdd(item.Template, itemRequest.Count))
|
|
{
|
|
// if it fails, add the amount to the existing amount
|
|
currencyAmounts[item.Template] += itemRequest.Count;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Used by `SptInsure`
|
|
// Handle differently, `id` is the money type tpl
|
|
var currencyTpl = itemRequest.Id;
|
|
// sometimes the currency can be in two parts, so it fails to tryadd the second part
|
|
if (!currencyAmounts.TryAdd(currencyTpl, itemRequest.Count))
|
|
{
|
|
// if it fails, add the amount to the existing amount
|
|
currencyAmounts[currencyTpl] += itemRequest.Count;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track the total amount of all currencies.
|
|
var totalCurrencyAmount = 0d;
|
|
|
|
// Loop through each type of currency involved in the trade.
|
|
foreach (var (currencyTpl, currencyAmount) in currencyAmounts)
|
|
{
|
|
if (currencyAmount <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
totalCurrencyAmount += currencyAmount.Value;
|
|
|
|
// Find money stacks in inventory and remove amount needed + update output object to inform client of changes
|
|
AddPaymentToOutput(pmcData, currencyTpl, currencyAmount.Value, 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),
|
|
_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);
|
|
}
|
|
|
|
if (_logger.IsLogEnabled(LogLevel.Debug))
|
|
{
|
|
_logger.Debug("Item(s) taken. Status OK.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the item price of a specific traders assort
|
|
/// </summary>
|
|
/// <param name="traderAssortId"> ID of the assort to look up</param>
|
|
/// <param name="traderId"> ID of trader with assort </param>
|
|
/// <returns> Handbook rouble price of the item </returns>
|
|
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 == 0)
|
|
{
|
|
_logger.Debug(
|
|
$"No item price found for {purchasedAssortItem.Template} on trader: {traderId} in assort: {traderAssortId}"
|
|
);
|
|
|
|
return 1;
|
|
}
|
|
|
|
return assortItemPriceRouble;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Receive money back after selling
|
|
/// </summary>
|
|
/// <param name="pmcData"> PMC Profile</param>
|
|
/// <param name="amountToSend"> Money to send back </param>
|
|
/// <param name="request"> Sell Trade request data </param>
|
|
/// <param name="output"> Client response </param>
|
|
/// <param name="sessionID"> Session ID </param>
|
|
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 -= (int)(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(item);
|
|
|
|
if (skipSendingMoneyToStash)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create single currency item with all currency on it
|
|
var 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)
|
|
{
|
|
var 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove currency from player stash/inventory and update client object with changes
|
|
/// </summary>
|
|
/// <param name="pmcData"> Player profile to find and remove currency from</param>
|
|
/// <param name="currencyTpl"> Type of currency to pay </param>
|
|
/// <param name="amountToPay"> Money value to pay </param>
|
|
/// <param name="sessionID"> Session ID </param>
|
|
/// <param name="output"> Client response </param>
|
|
public void AddPaymentToOutput(
|
|
PmcData pmcData,
|
|
string currencyTpl,
|
|
double amountToPay,
|
|
string sessionID,
|
|
ItemEventRouterResponse output
|
|
)
|
|
{
|
|
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(
|
|
0d,
|
|
(accumulator, item) => accumulator + item.Upd.StackObjectsCount.Value
|
|
);
|
|
|
|
// 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", // Typo, needs locale updated if fixed
|
|
new { amountToPay, amountAvailable }
|
|
)
|
|
);
|
|
_httpResponseUtil.AppendErrorToOutput(
|
|
output,
|
|
_localisationService.GetText(
|
|
"payment-not_enough_money_to_complete_transation_short",
|
|
amountToPay
|
|
), // Typo, needs locale updated if fixed
|
|
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(profileMoneyItem);
|
|
}
|
|
|
|
if (leftToPay == 0)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get all money stacks in inventory and prioritise items in stash
|
|
/// Ignore locked stacks
|
|
/// Prioritise the lowest sized stack
|
|
/// </summary>
|
|
/// <param name="pmcData"> Player profile </param>
|
|
/// <param name="currencyTpl"> Currency to find </param>
|
|
/// <param name="playerStashId"> Players stash ID </param>
|
|
/// <returns> List of sorted money items </returns>
|
|
// TODO - ensure money in containers inside secure container are LAST
|
|
protected List<Item> GetSortedMoneyItemsInInventory(
|
|
PmcData pmcData,
|
|
string currencyTpl,
|
|
string playerStashId
|
|
)
|
|
{
|
|
// Get money stacks player has
|
|
var moneyItemsInInventory = _itemHelper.FindBarterItems(
|
|
"tpl",
|
|
pmcData.Inventory.Items,
|
|
currencyTpl
|
|
);
|
|
if (moneyItemsInInventory.Count == 0)
|
|
{
|
|
_logger.Debug($"No {currencyTpl} money items found in inventory");
|
|
|
|
return moneyItemsInInventory;
|
|
}
|
|
|
|
// Create a cache inventory items with a bool of being in stash or not
|
|
var itemsInStashCache = GetItemInStashCache(pmcData.Inventory.Items, playerStashId);
|
|
|
|
// Filter out 'Locked' money stacks as they cannot be used
|
|
var noLocked = moneyItemsInInventory.Where(moneyItem =>
|
|
moneyItem.Upd.PinLockState != PinLockState.Locked
|
|
);
|
|
if (noLocked.Any())
|
|
{
|
|
// We found unlocked money
|
|
moneyItemsInInventory = noLocked.ToList();
|
|
}
|
|
|
|
// Prioritise items in stash to top of array
|
|
moneyItemsInInventory.Sort(
|
|
(a, b) => PrioritiseStashSort(a, b, pmcData.Inventory.Items, itemsInStashCache)
|
|
);
|
|
|
|
return moneyItemsInInventory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a dictionary of all items from player inventory that are in the players stash
|
|
/// </summary>
|
|
/// <param name="items">Inventory items to check</param>
|
|
/// <param name="playerStashId">Id of players stash</param>
|
|
/// <returns>Dictionary</returns>
|
|
protected IReadOnlyDictionary<string, InventoryLocation> GetItemInStashCache(
|
|
List<Item> items,
|
|
string playerStashId
|
|
)
|
|
{
|
|
var itemsInStashCache = new Dictionary<string, InventoryLocation>();
|
|
foreach (var inventoryItem in items)
|
|
{
|
|
itemsInStashCache.TryAdd(
|
|
inventoryItem.Id,
|
|
GetItemLocation(inventoryItem.Id, items, playerStashId)
|
|
);
|
|
}
|
|
|
|
return itemsInStashCache;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get stacks of money from player inventory, ordered by priority to use from
|
|
/// Post-raid healing would often take money out of the players pockets/secure container.
|
|
/// Return money stacks in root of stash first, with the smallest stacks taking priority
|
|
/// Stacks inside secure are returned last
|
|
/// </summary>
|
|
/// <param name="a"> First money stack item </param>
|
|
/// <param name="b"> Second money stack item </param>
|
|
/// <param name="inventoryItems"> Players inventory items</param>
|
|
/// <param name="itemInStashCache">Cache of item IDs and if they're in stash</param>
|
|
/// <returns> Sort order, -1 if A has priority, 1 if B has priority, 0 if they match </returns>
|
|
protected int PrioritiseStashSort(
|
|
Item a,
|
|
Item b,
|
|
List<Item> inventoryItems,
|
|
IReadOnlyDictionary<string, InventoryLocation> itemInStashCache
|
|
)
|
|
{
|
|
// Get the location of A and B
|
|
itemInStashCache.TryGetValue(a.Id, out var aLocation);
|
|
itemInStashCache.TryGetValue(b.Id, out var bLocation);
|
|
|
|
// Helper fields
|
|
var aInStash = aLocation == InventoryLocation.Stash;
|
|
var bInStash = bLocation == InventoryLocation.Stash;
|
|
var aInSecure = aLocation == InventoryLocation.Secure;
|
|
var bInSecure = bLocation == InventoryLocation.Secure;
|
|
var onlyAInStash = aInStash && !bInStash;
|
|
var onlyBInStash = !aInStash && bInStash;
|
|
var bothInStash = aInStash && bInStash;
|
|
|
|
if (bothInStash)
|
|
{
|
|
// Determine if they're in containers
|
|
var aInContainer = string.Equals(
|
|
a.SlotId,
|
|
"main",
|
|
StringComparison.InvariantCultureIgnoreCase
|
|
);
|
|
var bInContainer = string.Equals(
|
|
b.SlotId,
|
|
"main",
|
|
StringComparison.InvariantCultureIgnoreCase
|
|
);
|
|
|
|
// Return item not in container
|
|
var compare = aInContainer.CompareTo(bInContainer);
|
|
if (compare != 0)
|
|
{
|
|
return compare;
|
|
}
|
|
|
|
// Both in containers, deprioritized item in 'bad' container
|
|
if (aInContainer && bInContainer)
|
|
{
|
|
// Containers where taking money from would inconvenience player
|
|
var aImmediateParent = inventoryItems.FirstOrDefault(item => item.Id == a.ParentId);
|
|
var bImmediateParent = inventoryItems.FirstOrDefault(item => item.Id == b.ParentId);
|
|
|
|
var aInDeprioContainer = _inventoryConfig.DeprioritisedMoneyContainers.Contains(
|
|
aImmediateParent.Template
|
|
);
|
|
var bInDeprioContainer = _inventoryConfig.DeprioritisedMoneyContainers.Contains(
|
|
bImmediateParent.Template
|
|
);
|
|
|
|
// Prioritize B
|
|
if (!aInDeprioContainer && bInDeprioContainer)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
// Prioritize A
|
|
if (aInDeprioContainer && !bInDeprioContainer)
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
// Both in bad containers, fall out of IF and run GetPriorityBySmallestStackSize
|
|
}
|
|
|
|
return GetPriorityBySmallestStackSize(a, b);
|
|
}
|
|
|
|
// Prioritise A
|
|
if (onlyAInStash)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
// Prioritise B
|
|
if (onlyBInStash)
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
// A in secure, B not, prioritise B
|
|
if (aInSecure && !bInSecure)
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
// B in secure, A not, prioritise A
|
|
if (!aInSecure && bInSecure)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
// Both in secure
|
|
if (aInSecure && bInSecure)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
// They match / we don't know
|
|
return 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get priority of items based on their stack size
|
|
/// Smallest stack size has priority
|
|
/// </summary>
|
|
/// <param name="a">Item A</param>
|
|
/// <param name="b">Item B</param>
|
|
/// <returns>-1 = a, 1 = b, 0 = same</returns>
|
|
protected static int GetPriorityBySmallestStackSize(Item a, Item b)
|
|
{
|
|
var aStackSize = a.Upd?.StackObjectsCount ?? 1;
|
|
var bStackSize = b.Upd?.StackObjectsCount ?? 1;
|
|
|
|
return aStackSize.CompareTo(bStackSize);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively check items parents to see if it is inside the players inventory, not stash
|
|
/// </summary>
|
|
/// <param name="itemId"> Item ID to check </param>
|
|
/// <param name="inventoryItems"> Player inventory </param>
|
|
/// <param name="playerStashId"> Players stash ID </param>
|
|
/// <returns> True if it's in inventory </returns>
|
|
protected InventoryLocation GetItemLocation(
|
|
string itemId,
|
|
List<Item> inventoryItems,
|
|
string playerStashId
|
|
)
|
|
{
|
|
var inventoryItem = inventoryItems.FirstOrDefault(item => item.Id == itemId);
|
|
if (inventoryItem is null)
|
|
{
|
|
// Doesn't exist
|
|
return InventoryLocation.Other;
|
|
}
|
|
|
|
// is root item and its parent is the player stash
|
|
if (inventoryItem.Id == playerStashId)
|
|
{
|
|
return InventoryLocation.Stash;
|
|
}
|
|
|
|
// is child item and its parent is a root item
|
|
if (inventoryItem.SlotId == "hideout")
|
|
{
|
|
return InventoryLocation.Stash;
|
|
}
|
|
|
|
if (inventoryItem.SlotId == "SecuredContainer")
|
|
{
|
|
return InventoryLocation.Secure;
|
|
}
|
|
|
|
// Recursive call for parentId
|
|
return GetItemLocation(inventoryItem.ParentId, inventoryItems, playerStashId);
|
|
}
|
|
|
|
protected enum InventoryLocation
|
|
{
|
|
Other = 0,
|
|
Stash = 1,
|
|
Secure = 2,
|
|
}
|
|
}
|