Refactor SellItemToTrader logic (#403)

* Refactor SellItemToTrader logic

* add comments and make QuestHelper a singleton

* add localization for error

* grammar
This commit is contained in:
Cj
2025-06-18 08:14:35 -04:00
committed by GitHub
parent 9e991372b8
commit 89f4d10faa
3 changed files with 143 additions and 87 deletions
@@ -604,6 +604,7 @@
"quest-no_skill_found": "Skill %s not found",
"quest-unable_to_find_compare_condition": "Unrecognised Comparison Method: %s",
"quest-unable_to_find_quest_in_db": "Quest id: {{questId}} with type: {{questType}} not found in database",
"quest-unable_to_find_quest_in_db_no_type": "Quest id: {{questId}} not found in db",
"quest-unable_to_find_quest_in_db_no_quest_rewards": "Unable to find quest: %s in db, unable to give quest rewards to player",
"quest-unable_to_find_repeatable_to_replace": "Unable to find repeatable quest in profile to replace, skipping",
"quest-unable_to_find_trader_in_profile": "Unable to find trader: %s in profile",
@@ -5,6 +5,7 @@ using SPTarkov.Server.Core.Models.Eft.Common;
using SPTarkov.Server.Core.Models.Eft.Common.Tables;
using SPTarkov.Server.Core.Models.Eft.ItemEvent;
using SPTarkov.Server.Core.Models.Eft.Quests;
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;
@@ -17,7 +18,7 @@ using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel;
namespace SPTarkov.Server.Core.Helpers;
[Injectable]
[Injectable(InjectionType.Singleton)]
public class QuestHelper(
ISptLogger<QuestHelper> _logger,
TimeUtil _timeUtil,
@@ -39,6 +40,10 @@ public class QuestHelper(
protected readonly HashSet<QuestStatusEnum> _startedOrAvailToFinish = [QuestStatusEnum.Started, QuestStatusEnum.AvailableForFinish];
protected readonly QuestConfig _questConfig = _configServer.GetConfig<QuestConfig>();
// We need to keep track of quests with `SellItemToTrader` finish conditions to avoid expensive lookups during trading.
// NOTE: DO NOT ACCESS ME DIRECTLY! use GetSellToTraderQuests()
protected readonly Dictionary<string, List<QuestCondition>> _sellToTraderQuestConditionCache = [];
/// <summary>
/// Get status of a quest in player profile by its id
/// </summary>
@@ -260,10 +265,10 @@ public class QuestHelper(
}
/**
*
* @param pmcData
* @param newState
* @param acceptedQuest
*
* @param pmcData
* @param newState
* @param acceptedQuest
*/
/// <summary>
@@ -664,6 +669,134 @@ public class QuestHelper(
return updatedQuest;
}
/// <summary>
/// Get all quests with finish condition `SellItemToTrader`.
/// The first time this method is called it will cache the conditions by quest id in `_sellToTraderQuestConditionCache` and return that thereafter.
/// </summary>
/// <returns>List of quests with `SellItemToTrader` finish conditions</returns>
protected Dictionary<string, List<QuestCondition>> GetSellToTraderQuests()
{
// Cache is hydrated, return it.
if (_sellToTraderQuestConditionCache.Count != 0)
{
return _sellToTraderQuestConditionCache;
}
// Hydrate the cache.
foreach (var quest in GetQuestsFromDb())
{
foreach (var cond in quest.Conditions.AvailableForFinish)
{
if (cond.ConditionType != "SellItemToTrader")
{
continue;
}
if (!_sellToTraderQuestConditionCache.TryGetValue(quest.Id, out var questConditions))
{
questConditions ??= [];
questConditions.Add(cond);
_sellToTraderQuestConditionCache.Add(quest.Id, questConditions);
continue;
}
questConditions.Add(cond);
}
}
if (_logger.IsLogEnabled(LogLevel.Debug))
{
_logger.Debug($"_sellToTraderQuestConditionCache hydrated with {_sellToTraderQuestConditionCache.Count} quests");
}
return _sellToTraderQuestConditionCache;
}
/// <summary>
/// Get all active condition counters for `SellItemToTrader` conditions
/// </summary>
/// <param name="pmcData">Profile to check</param>
/// <returns>List of active TaskConditionCounters</returns>
protected List<TaskConditionCounter>? GetActiveSellToTraderConditionCounters(PmcData pmcData)
{
return pmcData.TaskConditionCounters?.Values.Where(condition => GetSellToTraderQuests().ContainsKey(condition.SourceId)
&& condition.Type == "SellItemToTrader").ToList();
}
/// <summary>
/// Look over all active conditions and increment them as needed
/// </summary>
/// <param name="profileWithItemsToSell">profile selling the items</param>
/// <param name="profileToReceiveMoney">profile to recieve the money</param>
/// <param name="sellRequest">request with items to sell</param>
public void IncrementSoldToTraderCounters(
PmcData profileWithItemsToSell,
PmcData profileToReceiveMoney,
ProcessSellTradeRequestData sellRequest
)
{
var activeConditionCounters = GetActiveSellToTraderConditionCounters(profileToReceiveMoney);
// No active conditions, exit
if (activeConditionCounters is null || activeConditionCounters.Count == 0)
{
return;
}
var sellToTraderQuests = GetSellToTraderQuests();
foreach (var counter in activeConditionCounters)
{
// Condition is in profile, but quest doesn't exist in database
if (!sellToTraderQuests.TryGetValue(counter.SourceId, out var conditions))
{
_logger.Error(_localisationService.GetText("unable_to_find_quest_in_db_no_type", counter.SourceId));
continue;
}
foreach (var condition in conditions)
{
IncrementSoldToTraderCounter(profileWithItemsToSell, counter, condition, sellRequest);
}
}
}
/// <summary>
/// Increment an individual condition counter
/// </summary>
/// <param name="profileWithItemsToSell">Profile selling the items</param>
/// <param name="taskCounter">condition counter to increment</param>
/// <param name="questCondition">quest condtion to check for valid items on</param>
/// <param name="sellRequest">sell request of items sold</param>
protected void IncrementSoldToTraderCounter(
PmcData profileWithItemsToSell,
TaskConditionCounter taskCounter,
QuestCondition questCondition,
ProcessSellTradeRequestData sellRequest
)
{
var itemsTplsThatIncrement = questCondition.Target;
foreach (var itemSoldToTrader in sellRequest.Items)
{
// Get sold items' details from profile
var itemDetails = profileWithItemsToSell.Inventory?.Items?.FirstOrDefault(inventoryItem => inventoryItem.Id == itemSoldToTrader.Id
);
if (itemDetails is null)
{
_logger.Error(_localisationService.GetText("trader-unable_to_find_inventory_item_for_selltotrader_counter", taskCounter.SourceId));
continue;
}
// Is sold item on the increment list
if (itemsTplsThatIncrement.List.Contains(itemDetails.Template))
{
taskCounter.Value += itemSoldToTrader.Count;
}
}
}
/// <summary>
/// Fail a quest in a player profile
/// </summary>
@@ -1007,7 +1140,7 @@ public class QuestHelper(
var completedQuestId = request.QuestId;
// Keep a copy of player quest statuses from their profile (Must be gathered prior to applyQuestReward() & failQuests())
var clientQuestsClone = _cloner.Clone(GetClientQuests(sessionID));
var clientQuestsClone = _cloner.Clone(GetClientQuests(sessionID));
const QuestStatusEnum newQuestState = QuestStatusEnum.Success;
UpdateQuestState(pmcData, newQuestState, completedQuestId);
@@ -21,6 +21,7 @@ public class TradeHelper(
DatabaseService _databaseService,
TraderHelper _traderHelper,
ItemHelper _itemHelper,
QuestHelper _questHelper,
PaymentService _paymentService,
FenceService _fenceService,
LocalisationService _localisationService,
@@ -270,14 +271,8 @@ public class TradeHelper(
ItemEventRouterResponse output
)
{
// TODO - make more generic to support all quests that have this condition type
// Try to reduce perf hit as this is expensive to do every sale
// MUST OCCUR PRIOR TO ITEMS BEING REMOVED FROM INVENTORY
if (sellRequest.TransactionId == Traders.RAGMAN)
// Edge case, `Circulate` quest needs to track when certain items are sold to him
{
IncrementCirculateSoldToTraderCounter(profileWithItemsToSell, profileToReceiveMoney, sellRequest);
}
// Check for and increment SoldToTrader condition counters
_questHelper.IncrementSoldToTraderCounters(profileWithItemsToSell, profileToReceiveMoney, sellRequest);
const string pattern = @"\s+";
@@ -318,79 +313,6 @@ public class TradeHelper(
_paymentService.GiveProfileMoney(profileToReceiveMoney, sellRequest.Price, sellRequest, output, sessionID);
}
protected void IncrementCirculateSoldToTraderCounter(
PmcData profileWithItemsToSell,
PmcData profileToReceiveMoney,
ProcessSellTradeRequestData sellRequest
)
{
const string circulateQuestId = "6663149f1d3ec95634095e75";
var activeCirculateQuest = profileToReceiveMoney.Quests.FirstOrDefault(quest => quest.QId == circulateQuestId && quest.Status == QuestStatusEnum.Started
);
// Player not on Circulate quest ,exit
if (activeCirculateQuest is null)
{
return;
}
// Find related task condition
var taskCondition = profileToReceiveMoney.TaskConditionCounters?.Values.FirstOrDefault(condition =>
condition.SourceId == circulateQuestId && condition.Type == "SellItemToTrader"
);
// No relevant condition in profile, nothing to increment
if (taskCondition is null)
{
_logger.Error($"Unable to find `sellToTrader` task counter for {circulateQuestId} quest in profile, skipping");
return;
}
// Condition exists in profile
var circulateQuestDb = _databaseService.GetQuests();
if (!circulateQuestDb.TryGetValue(circulateQuestId, out _))
{
_logger.Error($"Unable to find quest: {circulateQuestId} in db, skipping");
return;
}
// Get sellToTrader condition from quest
var sellItemToTraderCondition = circulateQuestDb.GetValueOrDefault(circulateQuestId)?
.Conditions?.AvailableForFinish?.FirstOrDefault(condition => condition.ConditionType == "SellItemToTrader"
);
// Quest doesn't have a sellItemToTrader condition, nothing to do
if (sellItemToTraderCondition is null)
{
_logger.Error(_localisationService.GetText("quest-unable_to_find_selltotrader_counter", circulateQuestId));
return;
}
// Iterate over items sold to trader
var itemsTplsThatIncrement = sellItemToTraderCondition.Target;
foreach (var itemSoldToTrader in sellRequest.Items)
{
// Get sold items' details from profile
var itemDetails = profileWithItemsToSell.Inventory?.Items?.FirstOrDefault(inventoryItem => inventoryItem.Id == itemSoldToTrader.Id
);
if (itemDetails is null)
{
_logger.Error(_localisationService.GetText("trader-unable_to_find_inventory_item_for_selltotrader_counter", circulateQuestId));
continue;
}
// Is sold item on the increment list
if (itemsTplsThatIncrement.List.Contains(itemDetails.Template))
{
taskCondition.Value += itemSoldToTrader.Count;
}
}
}
/// <summary>
/// Traders allow a limited number of purchases per refresh cycle (default 60 mins)
/// </summary>