Files
SPT-Server-Build/Libraries/SPTarkov.Server.Core/Helpers/TradeHelper.cs
T
2025-07-22 20:43:08 +01:00

422 lines
16 KiB
C#

using System.Text.RegularExpressions;
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Extensions;
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.Utils;
using SPTarkov.Server.Core.Servers;
using SPTarkov.Server.Core.Services;
using SPTarkov.Server.Core.Utils;
using SPTarkov.Server.Core.Utils.Cloners;
using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel;
namespace SPTarkov.Server.Core.Helpers;
[Injectable]
public class TradeHelper(
ISptLogger<TradeHelper> logger,
TraderHelper traderHelper,
ItemHelper itemHelper,
QuestHelper questHelper,
PaymentService paymentService,
FenceService fenceService,
ServerLocalisationService serverLocalisationService,
HttpResponseUtil httpResponseUtil,
InventoryHelper inventoryHelper,
RagfairServer ragfairServer,
TraderAssortHelper traderAssortHelper,
TraderPurchasePersisterService traderPurchasePersisterService,
ICloner cloner
)
{
protected static readonly Lock buyLock = new();
/// <summary>
/// Buy item from flea or trader
/// </summary>
/// <param name="pmcData">Player profile</param>
/// <param name="buyRequestData">data from client</param>
/// <param name="sessionID">Session id</param>
/// <param name="foundInRaid">Should item be found in raid</param>
/// <param name="output">Item event router response</param>
public void BuyItem(
PmcData pmcData,
ProcessBuyTradeRequestData buyRequestData,
MongoId sessionID,
bool foundInRaid,
ItemEventRouterResponse output
)
{
lock (buyLock)
{
List<Item> offerItems = [];
Action<int>? buyCallback;
if (
string.Equals(
buyRequestData.TransactionId,
"ragfair",
StringComparison.OrdinalIgnoreCase
)
)
{
// Called when player purchases PMC offer from ragfair
buyCallback = buyCount =>
{
var allOffers = ragfairServer.GetOffers();
// We store ragfair offerId in buyRequestData.item_id
var offerWithItem = allOffers.FirstOrDefault(x =>
x.Id == buyRequestData.ItemId
);
var itemPurchased = offerWithItem.Items.FirstOrDefault();
// Ensure purchase does not exceed trader item limit
if (itemPurchased.HasBuyRestrictions())
{
CheckPurchaseIsWithinTraderItemLimit(
sessionID,
pmcData,
buyRequestData.TransactionId,
itemPurchased,
buyRequestData.ItemId,
buyCount
);
// Decrement trader item count
var itemPurchaseDetails = new PurchaseDetails
{
Items =
[
new PurchaseItems
{
ItemId = buyRequestData.ItemId,
Count = buyCount,
},
],
TraderId = buyRequestData.TransactionId,
};
traderHelper.AddTraderPurchasesToPlayerProfile(
sessionID,
itemPurchaseDetails,
itemPurchased
);
}
};
// buyCallback = BuyCallback1;
// Get raw offer from ragfair, clone to prevent altering offer itself
var allOffers = ragfairServer.GetOffers();
var offerWithItemCloned = cloner.Clone(
allOffers.FirstOrDefault(x => x.Id == buyRequestData.ItemId)
);
offerItems = offerWithItemCloned.Items;
}
else if (buyRequestData.TransactionId == Traders.FENCE)
{
buyCallback = buyCount =>
{
// Update assort/flea item values
var traderAssorts = traderHelper
.GetTraderAssortsByTraderId(buyRequestData.TransactionId)
.Items;
var itemPurchased = traderAssorts.FirstOrDefault(assort =>
assort.Id == buyRequestData.ItemId
);
// Decrement trader item count
itemPurchased.Upd.StackObjectsCount -= buyCount;
fenceService.AmendOrRemoveFenceOffer(buyRequestData.ItemId, buyCount);
};
var fenceItems = fenceService.GetRawFenceAssorts().Items;
var rootItemIndex = fenceItems.FindIndex(item => item.Id == buyRequestData.ItemId);
if (rootItemIndex == -1)
{
if (logger.IsLogEnabled(LogLevel.Debug))
{
logger.Debug(
$"Tried to buy item {buyRequestData.ItemId} from fence that no longer exists"
);
}
var message = serverLocalisationService.GetText(
"ragfair-offer_no_longer_exists"
);
httpResponseUtil.AppendErrorToOutput(output, message);
return;
}
offerItems = fenceItems.GetItemWithChildren(buyRequestData.ItemId);
}
else
{
buyCallback = buyCount =>
{
// Update assort/flea item values
var traderAssorts = traderHelper
.GetTraderAssortsByTraderId(buyRequestData.TransactionId)
.Items;
var itemPurchased = traderAssorts.FirstOrDefault(item =>
item.Id == buyRequestData.ItemId
);
// Ensure purchase does not exceed trader item limit
if (itemPurchased.HasBuyRestrictions())
// Will throw error if check fails
{
CheckPurchaseIsWithinTraderItemLimit(
sessionID,
pmcData,
buyRequestData.TransactionId,
itemPurchased,
buyRequestData.ItemId,
buyCount
);
}
// Check if trader has enough stock
if (itemPurchased.Upd.StackObjectsCount < buyCount)
{
throw new Exception(
$"Unable to purchase {buyCount} items, this would exceed the remaining stock left {itemPurchased.Upd.StackObjectsCount} from the traders assort: {buyRequestData.TransactionId} this refresh"
);
}
// Decrement trader item count
itemPurchased.Upd.StackObjectsCount -= buyCount;
if (itemPurchased.HasBuyRestrictions())
{
var itemPurchaseDat = new PurchaseDetails
{
Items =
[
new PurchaseItems
{
ItemId = buyRequestData.ItemId,
Count = buyCount,
},
],
TraderId = buyRequestData.TransactionId,
};
traderHelper.AddTraderPurchasesToPlayerProfile(
sessionID,
itemPurchaseDat,
itemPurchased
);
}
};
// Get all trader assort items
var traderItems = traderAssortHelper
.GetAssort(sessionID, buyRequestData.TransactionId)
.Items;
// Get item + children for purchase
var relevantItems = traderItems.GetItemWithChildren(buyRequestData.ItemId);
if (relevantItems.Count == 0)
{
logger.Error(
$"Purchased trader: {buyRequestData.TransactionId} offer: {buyRequestData.ItemId} has no items"
);
}
offerItems.AddRange(relevantItems);
}
// Get item details from db
var itemDbDetails = itemHelper.GetItem(offerItems.FirstOrDefault().Template).Value;
var itemMaxStackSize = itemDbDetails.Properties.StackMaxSize;
var itemsToSendTotalCount = buyRequestData.Count;
var itemsToSendRemaining = itemsToSendTotalCount;
// Construct array of items to send to player
List<List<Item>> itemsToSendToPlayer = [];
while (itemsToSendRemaining > 0)
{
var offerClone = cloner.Clone(offerItems);
// Handle stackable items that have a max stack size limit
var itemCountToSend = Math.Min(itemMaxStackSize ?? 0, itemsToSendRemaining ?? 0);
offerClone.FirstOrDefault().Upd.StackObjectsCount = itemCountToSend;
// Prevent any collisions
offerClone.RemapRootItemId();
if (offerClone.Count > 1)
{
itemHelper.ReparentItemAndChildren(offerClone.FirstOrDefault(), offerClone);
}
itemsToSendToPlayer.Add(offerClone);
// Remove amount of items added to player stash
itemsToSendRemaining -= itemCountToSend;
}
// Construct request
var request = new AddItemsDirectRequest
{
ItemsWithModsToAdd = itemsToSendToPlayer,
FoundInRaid = foundInRaid,
Callback = buyCallback,
UseSortingTable = false,
};
// Add items + their children to stash
inventoryHelper.AddItemsToStash(sessionID, request, pmcData, output);
if (output.Warnings?.Count > 0)
{
return;
}
// Pay for purchase
paymentService.PayMoney(pmcData, buyRequestData, sessionID, output);
if (output.Warnings?.Count > 0)
{
var errorMessage =
$"Transaction failed: {output.Warnings.FirstOrDefault().ErrorMessage}";
httpResponseUtil.AppendErrorToOutput(
output,
errorMessage,
BackendErrorCodes.UnknownTradingError
);
}
}
}
/// <summary>
/// Sell item to trader
/// </summary>
/// <param name="profileWithItemsToSell">Profile to remove items from</param>
/// <param name="profileToReceiveMoney">Profile to accept the money for selling item</param>
/// <param name="sellRequest">Request data</param>
/// <param name="sessionID">Session id</param>
/// <param name="output">Item event router response</param>
public void SellItem(
PmcData profileWithItemsToSell,
PmcData profileToReceiveMoney,
ProcessSellTradeRequestData sellRequest,
MongoId sessionID,
ItemEventRouterResponse output
)
{
// Check for and increment SoldToTrader condition counters
questHelper.IncrementSoldToTraderCounters(
profileWithItemsToSell,
profileToReceiveMoney,
sellRequest
);
const string pattern = @"\s+";
// Find item in inventory and remove it
foreach (var itemToBeRemoved in sellRequest.Items)
{
var itemIdToFind = Regex.Replace(itemToBeRemoved.Id, pattern, ""); // Strip out whitespace
// Find item in player inventory, or show error to player if not found
var matchingItemInInventory = profileWithItemsToSell.Inventory.Items.FirstOrDefault(x =>
x.Id == itemIdToFind
);
if (matchingItemInInventory is null)
{
var errorMessage =
$"Unable to sell item {itemToBeRemoved.Id}, cannot be found in player inventory";
logger.Error(errorMessage);
httpResponseUtil.AppendErrorToOutput(output, errorMessage);
return;
}
if (logger.IsLogEnabled(LogLevel.Debug))
{
logger.Debug(
$"Selling: id: {matchingItemInInventory.Id} tpl: {matchingItemInInventory.Template}"
);
}
if (sellRequest.TransactionId == Traders.FENCE)
{
fenceService.AddItemsToFenceAssort(
profileWithItemsToSell.Inventory.Items,
matchingItemInInventory
);
}
// Remove item from inventory + any child items it has
inventoryHelper.RemoveItem(
profileWithItemsToSell,
itemToBeRemoved.Id,
sessionID,
output
);
}
// Give player money for sold item(s)
paymentService.GiveProfileMoney(
profileToReceiveMoney,
sellRequest.Price,
sellRequest,
output,
sessionID
);
}
/// <summary>
/// Traders allow a limited number of purchases per refresh cycle (default 60 mins)
/// </summary>
/// <param name="sessionId">Session id</param>
/// <param name="pmcData">Profile making the purchase</param>
/// <param name="traderId">Trader assort is purchased from</param>
/// <param name="assortBeingPurchased">the item from trader being bought</param>
/// <param name="assortId">Id of assort being purchased</param>
/// <param name="count">How many of the item are being bought</param>
protected void CheckPurchaseIsWithinTraderItemLimit(
MongoId sessionId,
PmcData pmcData,
MongoId traderId,
Item assortBeingPurchased,
MongoId assortId,
double count
)
{
var traderPurchaseData = traderPurchasePersisterService.GetProfileTraderPurchase(
sessionId,
traderId,
assortBeingPurchased.Id
);
var traderItemPurchaseLimit = traderHelper.GetAccountTypeAdjustedTraderPurchaseLimit(
(double)assortBeingPurchased.Upd?.BuyRestrictionMax,
pmcData.Info.GameVersion
);
if ((traderPurchaseData?.PurchaseCount ?? 0) + count > traderItemPurchaseLimit)
{
throw new Exception(
$"Unable to purchase: {count} items, this would exceed your purchase limit of {traderItemPurchaseLimit} from the trader: {traderId} assort: {assortId} this refresh"
);
}
}
}
public record PurchaseDetails
{
public List<PurchaseItems> Items { get; set; }
public MongoId TraderId { get; set; }
}
public record PurchaseItems
{
public MongoId ItemId { get; set; }
public double Count { get; set; }
}