Files
SPT-Server-Build/Libraries/SPTarkov.Server.Core/Services/PaymentService.cs
T
2025-07-28 19:39:29 +00:00

560 lines
21 KiB
C#

using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Extensions;
using SPTarkov.Server.Core.Helpers;
using SPTarkov.Server.Core.Models.Common;
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,
HttpResponseUtil httpResponseUtil,
HandbookHelper handbookHelper,
TraderHelper traderHelper,
ItemHelper itemHelper,
InventoryHelper inventoryHelper,
ServerLocalisationService serverLocalisationService,
PaymentHelper paymentHelper,
ConfigServer configServer
)
{
protected readonly 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, MongoId sessionID, ItemEventRouterResponse output)
{
// May need to convert to trader currency
var trader = traderHelper.GetTrader(request.TransactionId, sessionID);
var payToTrader = request.TransactionId != "ragfair" && traderHelper.TraderExists(request.TransactionId);
// Track the amounts of each type of currency involved in the trade.
var currencyAmounts = new Dictionary<MongoId, 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
currencyAmounts.AddOrUpdate(item.Template, itemRequest.Count.Value);
}
}
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
currencyAmounts.AddOrUpdate(currencyTpl, itemRequest.Count.Value);
}
}
// Track the total amount of all currencies.
var totalCurrencyAmount = 0d;
// Convert id to mongoId if we know it'll be valid (trader id)
var requestTransactionId = payToTrader ? new MongoId(request.TransactionId) : MongoId.Empty(); // Likely flea, use default
// Loop through each type of currency involved in the trade.
foreach (var (currencyTpl, currencyAmount) in currencyAmounts)
{
if (currencyAmount <= 0)
{
continue;
}
totalCurrencyAmount += currencyAmount;
// Find money stacks in inventory and remove amount needed + update output object to inform client of changes
AddPaymentToOutput(pmcData, currencyTpl, 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, currencyTpl),
trader.Currency.Value.GetCurrencyTpl()
);
// Only update traders
pmcData.TradersInfo[requestTransactionId].SalesSum += costOfPurchaseInCurrency;
}
}
// If no currency-based payment is involved, handle it separately
if (totalCurrencyAmount == 0 && payToTrader)
{
logger.Debug(serverLocalisationService.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, requestTransactionId) ?? 0,
trader.Currency.Value.GetCurrencyTpl()
);
pmcData.TradersInfo[requestTransactionId].SalesSum += costOfPurchaseInCurrency;
}
if (payToTrader)
{
traderHelper.LevelUp(requestTransactionId, 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(MongoId traderAssortId, MongoId 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,
MongoId 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 = trader.Currency.Value.GetCurrencyTpl();
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 (!pmcData.IsItemInStash(item))
{
continue;
}
// Found currency item
if (item.Upd?.StackObjectsCount < currencyMaxStackSize)
{
if (item.Upd.StackObjectsCount + calcAmount > currencyMaxStackSize)
{
// calculate difference
calcAmount -= (int)(currencyMaxStackSize - item.Upd.StackObjectsCount);
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 = new MongoId(),
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,
MongoId currencyTpl,
double amountToPay,
MongoId sessionID,
ItemEventRouterResponse output
)
{
var moneyItemsInInventory = GetSortedMoneyItemsInInventory(pmcData, currencyTpl, pmcData.Inventory.Stash.Value);
//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(
serverLocalisationService.GetText(
"payment-not_enough_money_to_complete_transation", // Typo, needs locale updated if fixed
new { amountToPay, amountAvailable }
)
);
httpResponseUtil.AppendErrorToOutput(
output,
serverLocalisationService.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.Value;
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, MongoId currencyTpl, MongoId 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<MongoId, InventoryLocation> GetItemInStashCache(List<Item> items, MongoId playerStashId)
{
var itemsInStashCache = new Dictionary<MongoId, 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<MongoId, 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(MongoId itemId, List<Item> inventoryItems, MongoId 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,
}
}