Repeatable quest generation (Part 1) (#417)

* Refactor and breakout CompletionQuestGenerator.cs

* make `GenerateAvailableForFinish` protected
This commit is contained in:
Cj
2025-06-22 15:51:18 -04:00
committed by GitHub
parent 1087b32e13
commit 811791f7d3
4 changed files with 613 additions and 410 deletions
@@ -0,0 +1,421 @@
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Helpers;
using SPTarkov.Server.Core.Models.Eft.Common.Tables;
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.Services;
using SPTarkov.Server.Core.Utils;
using SPTarkov.Server.Core.Utils.Json;
namespace SPTarkov.Server.Core.Generators.RepeatableQuestGeneration;
[Injectable]
public class CompletionQuestGenerator(
ISptLogger<CompletionQuestGenerator> logger,
RepeatableQuestHelper repeatableQuestHelper,
RepeatableQuestRewardGenerator repeatableQuestRewardGenerator,
DatabaseService databaseService,
SeasonalEventService seasonalEventService,
LocalisationService localisationService,
RandomUtil randomUtil,
MathUtil mathUtil,
HashUtil hashUtil,
ItemHelper itemHelper
)
{
protected const int MaxRandomNumberAttempts = 6;
/// <summary>
/// Generates a valid Completion quest
/// </summary>
/// <param name="sessionId">session Id to generate the quest for</param>
/// <param name="pmcLevel">player's level for requested items and reward generation</param>
/// <param name="traderId">trader from which the quest will be provided</param>
/// <param name="repeatableConfig">
/// The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig
/// for the requested quest
/// </param>
/// <returns>quest type format for "Completion" (see assets/database/templates/repeatableQuests.json)</returns>
public RepeatableQuest? Generate(
string sessionId,
int pmcLevel,
string traderId,
RepeatableQuestConfig repeatableConfig
)
{
var completionConfig = repeatableConfig.QuestConfig.Completion;
var levelsConfig = repeatableConfig.RewardScaling.Levels;
var roublesConfig = repeatableConfig.RewardScaling.Roubles;
var quest = repeatableQuestHelper.GenerateRepeatableTemplate(
RepeatableQuestType.Completion,
traderId,
repeatableConfig.Side,
sessionId
);
if (quest is null)
{
logger.Error("Quest template null when attempting to create completion operational task.");
return null;
}
// Filter the items.json items to items the player must retrieve to complete quest: shouldn't be a quest item or "non-existent"
var itemsToRetrievePool = GetItemsToRetrievePool(
completionConfig,
repeatableConfig.RewardBlacklist
);
// Filter items within our budget
var (hashSet, budget) = GetItemsWithinBudget(pmcLevel, levelsConfig, roublesConfig, itemsToRetrievePool);
itemsToRetrievePool = hashSet;
// We also have the option to use whitelist and/or blacklist which is defined in repeatableQuests.json as
// [{"minPlayerLevel": 1, "itemIds": ["id1",...]}, {"minPlayerLevel": 15, "itemIds": ["id3",...]}]
if (repeatableConfig.QuestConfig.Completion.UseWhitelist)
{
itemsToRetrievePool = GetWhitelistedItemSelection(itemsToRetrievePool, pmcLevel);
}
if (repeatableConfig.QuestConfig.Completion.UseBlacklist)
{
itemsToRetrievePool = GetBlacklistedItemSelection(itemsToRetrievePool, pmcLevel);
}
// Filtering too harsh
if (itemsToRetrievePool.Count == 0)
{
logger.Error(
localisationService.GetText(
"repeatable-completion_quest_whitelist_too_small_or_blacklist_too_restrictive"
)
);
return null;
}
var selectedItems = GenerateAvailableForFinish(
quest, completionConfig, repeatableConfig, itemsToRetrievePool.ToList(), budget
);
quest.Rewards = repeatableQuestRewardGenerator.GenerateReward(
pmcLevel,
1,
traderId,
repeatableConfig,
completionConfig,
selectedItems
);
return quest;
}
/// <summary>
/// Generate a pool of item tpls the player should reasonably be able to retrieve
/// </summary>
/// <param name="completionConfig">Completion quest type config</param>
/// <param name="itemTplBlacklist">Item tpls to not add to pool</param>
/// <returns>Set of item tpls</returns>
protected HashSet<string> GetItemsToRetrievePool(
Completion completionConfig,
HashSet<string> itemTplBlacklist
)
{
// Get seasonal items that should not be added to pool as seasonal event is not active
var seasonalItems = seasonalEventService.GetInactiveSeasonalEventItems();
// Check for specific base classes which don't make sense as reward item
// also check if the price is greater than 0; there are some items whose price can not be found
return databaseService
.GetItems()
.Values.Where(itemTemplate =>
{
// Base "Item" item has no parent, ignore it
if (itemTemplate.Parent == string.Empty)
{
return false;
}
if (seasonalItems.Contains(itemTemplate.Id))
{
return false;
}
// Valid reward items share same logic as items to retrieve
return repeatableQuestRewardGenerator.IsValidRewardItem(
itemTemplate.Id,
itemTplBlacklist,
completionConfig.RequiredItemTypeBlacklist
);
})
.Select(item => item.Id)
.ToHashSet();
}
/// <summary>
/// Filter item pool down to items we can afford on our budget
/// </summary>
/// <param name="pmcLevel">Level of pmc</param>
/// <param name="levelsConfig">Levels config</param>
/// <param name="roublesConfig">Roubles config</param>
/// <param name="itemsToRetrievePool">Item pool</param>
/// <returns>Filtered items and roubles budget</returns>
protected (HashSet<string>, double) GetItemsWithinBudget(
int pmcLevel,
List<double> levelsConfig,
List<double> roublesConfig,
HashSet<string> itemsToRetrievePool)
{
// Be fair, don't value the items be more expensive than the reward
var multiplier = randomUtil.GetDouble(0.5, 1);
var roublesBudget = Math.Floor(
mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) * multiplier
);
// Make sure there is always a 5000 rouble budget available for selection
roublesBudget = Math.Max(roublesBudget, 5000d);
return (
itemsToRetrievePool.Where(itemTpl => itemHelper.GetItemPrice(itemTpl) < roublesBudget).ToHashSet(),
roublesBudget
);
}
/// <summary>
/// Filter item selection to items in the whitelist
/// </summary>
/// <param name="itemSelection">Item selection to filter</param>
/// <param name="pmcLevel">Level of pmc</param>
/// <returns>Filtered selection, or original if null or empty</returns>
protected HashSet<string> GetWhitelistedItemSelection(HashSet<string> itemSelection, int pmcLevel)
{
var itemWhitelist = databaseService
.GetTemplates()
.RepeatableQuests?.Data?.Completion?.ItemsWhitelist;
// Whitelist doesn't exist or is empty, return original
if (itemWhitelist is null || itemWhitelist.Count == 0)
{
return itemSelection;
}
// Filter and concatenate items according to current player level
var itemIdsWhitelisted = itemWhitelist
.Where(p => p.MinPlayerLevel <= pmcLevel)
.SelectMany(x => x.ItemIds)
.ToHashSet(); //.Aggregate((a, p) => a.Concat(p.ItemIds), []);
var filteredSelection = itemSelection
.Where(x =>
{
// Whitelist can contain item tpls and item base type ids
return itemIdsWhitelisted.Any(v => itemHelper.IsOfBaseclass(x, v))
|| itemIdsWhitelisted.Contains(x);
})
.ToHashSet();
// check if items are missing
// var flatList = itemSelection.reduce((a, il) => a.concat(il[0]), []);
// var missing = itemIdsWhitelisted.filter(l => !flatList.includes(l));
return filteredSelection;
}
/// <summary>
/// Filter item selection based on the blacklist
/// </summary>
/// <param name="itemSelection">Item selection to filter</param>
/// <param name="pmcLevel">Level of pmc</param>
/// <returns>Filtered selection, or original if null or empty</returns>
protected HashSet<string> GetBlacklistedItemSelection(HashSet<string> itemSelection, int pmcLevel)
{
var itemBlacklist = databaseService
.GetTemplates()
.RepeatableQuests?.Data?.Completion?.ItemsBlacklist;
// Blacklist doesn't exist or is empty, return original
if (itemBlacklist is null || itemBlacklist.Count == 0)
{
return itemSelection;
}
// Filter and concatenate the arrays according to current player level
var itemIdsBlacklisted = itemBlacklist
.Where(p => p.MinPlayerLevel <= pmcLevel)
.SelectMany(x => x.ItemIds)
.ToHashSet(); //.Aggregate(List<ItemsBlacklist> , (a, p) => a.Concat(p.ItemIds) );
var filteredSelection = itemSelection
.Where(x =>
{
return itemIdsBlacklisted.All(v => !itemHelper.IsOfBaseclass(x, v))
|| !itemIdsBlacklisted.Contains(x);
})
.ToHashSet();
return filteredSelection;
}
/// <summary>
/// Generate the available for finish conditions for this quest
/// </summary>
/// <param name="quest">Quest to add the conditions to</param>
/// <param name="completionConfig">Completion config</param>
/// <param name="repeatableConfig">Repeatable config</param>
/// <param name="itemSelection">Filtered item selection</param>
/// <param name="roublesBudget">Budget in roubles</param>
/// <returns>Chosen item template Ids</returns>
protected List<string> GenerateAvailableForFinish(
RepeatableQuest quest,
Completion completionConfig,
RepeatableQuestConfig repeatableConfig,
List<string> itemSelection,
double roublesBudget
)
{
// Store the indexes of items we are asking player to supply
var distinctItemsToRetrieveCount = randomUtil.GetInt(1, completionConfig.UniqueItemCount);
var chosenRequirementItemsTpls = new List<string>();
var usedItemIndexes = new HashSet<int>();
for (var i = 0; i < distinctItemsToRetrieveCount; i++)
{
var chosenItemIndex = randomUtil.RandInt(itemSelection.Count);
var found = false;
for (var j = 0; j < MaxRandomNumberAttempts; j++)
{
if (usedItemIndexes.Contains(chosenItemIndex))
{
chosenItemIndex = randomUtil.RandInt(itemSelection.Count);
}
else
{
found = true;
break;
}
}
if (!found)
{
logger.Error(
localisationService.GetText(
"repeatable-no_reward_item_found_in_price_range",
new { minPrice = 0, roublesBudget }
)
);
return chosenRequirementItemsTpls;
}
// Store index of item we've already chosen for later checking
usedItemIndexes.Add(chosenItemIndex);
var tplChosen = itemSelection[chosenItemIndex];
var itemPrice = itemHelper.GetItemPrice(tplChosen).Value;
var minValue = completionConfig.MinimumRequestedAmount;
var maxValue = completionConfig.MaximumRequestedAmount;
var value = minValue;
// Get the value range within budget
var x = (int)Math.Floor(roublesBudget / itemPrice);
maxValue = Math.Min(maxValue, x);
if (maxValue > minValue)
// If it doesn't blow the budget we have for the request, draw a random amount of the selected
// Item type to be requested
{
value = randomUtil.RandInt(minValue, maxValue + 1);
}
roublesBudget -= value * itemPrice;
// Push a CompletionCondition with the item and the amount of the item into quest
chosenRequirementItemsTpls.Add(tplChosen);
quest.Conditions.AvailableForFinish.Add(
GenerateCondition(
tplChosen,
value,
repeatableConfig.QuestConfig.Completion
)
);
// Is there budget left for more items
if (roublesBudget > 0)
{
// Reduce item pool to fit budget
itemSelection = itemSelection
.Where(tpl => itemHelper.GetItemPrice(tpl) < roublesBudget)
.ToList();
if (itemSelection.Count == 0)
{
// Nothing fits new budget, exit
break;
}
continue;
}
break;
}
return chosenRequirementItemsTpls;
}
/// <summary>
/// A repeatable quest, besides some more or less static components, exists of reward and condition (see
/// assets/database/templates/repeatableQuests.json)
/// This is a helper method for GenerateCompletionQuest to create a completion condition (of which a completion quest
/// theoretically can have many)
/// </summary>
/// <param name="itemTpl">Id of the item to request</param>
/// <param name="value">Amount of items of this specific type to request</param>
/// <param name="completionConfig">Completion config from quest.json</param>
/// <returns>object of "Completion"-condition</returns>
protected QuestCondition GenerateCondition(
string itemTpl,
double value,
Completion completionConfig
)
{
var onlyFoundInRaid = completionConfig.RequiredItemsAreFiR;
var minDurability = itemHelper.IsOfBaseclasses(
itemTpl,
[BaseClasses.WEAPON, BaseClasses.ARMOR]
)
? randomUtil.GetArrayValue(
[
completionConfig.RequiredItemMinDurabilityMinMax.Min,
completionConfig.RequiredItemMinDurabilityMinMax.Max,
]
)
: 0;
// Dog tags MUST NOT be FiR for them to work
if (itemHelper.IsDogtag(itemTpl))
{
onlyFoundInRaid = false;
}
return new QuestCondition
{
Id = hashUtil.Generate(),
Index = 0,
ParentId = "",
DynamicLocale = true,
VisibilityConditions = [],
Target = new ListOrT<string>([itemTpl], null),
Value = value,
MinDurability = minDurability,
MaxDurability = 100,
DogtagLevel = 0,
OnlyFoundInRaid = onlyFoundInRaid,
IsEncoded = false,
ConditionType = "HandoverItem",
};
}
}
@@ -1,4 +1,5 @@
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Generators.RepeatableQuestGeneration;
using SPTarkov.Server.Core.Helpers;
using SPTarkov.Server.Core.Models.Eft.Common;
using SPTarkov.Server.Core.Models.Eft.Common.Tables;
@@ -13,9 +14,11 @@ using SPTarkov.Server.Core.Utils.Cloners;
using SPTarkov.Server.Core.Utils.Collections;
using SPTarkov.Server.Core.Utils.Json;
using BodyParts = SPTarkov.Server.Core.Constants.BodyParts;
using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel;
namespace SPTarkov.Server.Core.Generators;
[Obsolete("In the process of being removed, do NOT add any new logic!!")]
[Injectable]
public class RepeatableQuestGenerator(
ISptLogger<RepeatableQuestGenerator> _logger,
@@ -28,8 +31,9 @@ public class RepeatableQuestGenerator(
DatabaseService _databaseService,
LocalisationService _localisationService,
ConfigServer _configServer,
SeasonalEventService _seasonalEventService,
ICloner _cloner
ICloner _cloner,
// This is temporary while this is being refactored, eventually these will all live in the RepeatableQuestController.
CompletionQuestGenerator _completionQuestGenerator
)
{
/// <summary>
@@ -72,11 +76,24 @@ public class RepeatableQuestGenerator(
var traders = repeatableConfig
.TraderWhitelist.Where(x => x.QuestTypes.Contains(questType))
.Select(x => x.TraderId)
// filter out locked traders
.Where(x => pmcTraderInfo[x].Unlocked.GetValueOrDefault(false))
.ToList();
// filter out locked traders
traders = traders.Where(x => pmcTraderInfo[x].Unlocked.GetValueOrDefault(false)).ToList();
var traderId = _randomUtil.DrawRandomFromList(traders).FirstOrDefault();
if (traderId is null)
{
// TODO: Localize me!
_logger.Error("Could not draw traderId from whitelist during repeatable quest generation");
return null;
}
if (_logger.IsLogEnabled(LogLevel.Debug))
{
_logger.Debug($"Generating operation task type: {questType} for {traderId}");
}
return questType switch
{
"Elimination" => GenerateEliminationQuest(
@@ -86,7 +103,7 @@ public class RepeatableQuestGenerator(
questTypePool,
repeatableConfig
),
"Completion" => GenerateCompletionQuest(
"Completion" => _completionQuestGenerator.Generate(
sessionId,
pmcLevel,
traderId,
@@ -417,8 +434,8 @@ public class RepeatableQuestGenerator(
// crazy maximum difficulty will lead to a higher difficulty reward gain factor than 1
var difficulty = _mathUtil.MapToRange(curDifficulty, minDifficulty, maxDifficulty, 0.5, 2);
var quest = GenerateRepeatableTemplate(
"Elimination",
var quest = _repeatableQuestHelper.GenerateRepeatableTemplate(
RepeatableQuestType.Elimination,
traderId,
repeatableConfig.Side,
sessionId
@@ -595,301 +612,6 @@ public class RepeatableQuestGenerator(
return killConditionProps;
}
/// <summary>
/// Generates a valid Completion quest
/// </summary>
/// <param name="pmcLevel">player's level for requested items and reward generation</param>
/// <param name="traderId">trader from which the quest will be provided</param>
/// <param name="repeatableConfig">
/// The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig
/// for the requested quest
/// </param>
/// <returns>quest type format for "Completion" (see assets/database/templates/repeatableQuests.json)</returns>
protected RepeatableQuest? GenerateCompletionQuest(
string sessionId,
int pmcLevel,
string traderId,
RepeatableQuestConfig repeatableConfig
)
{
var completionConfig = repeatableConfig.QuestConfig.Completion;
var levelsConfig = repeatableConfig.RewardScaling.Levels;
var roublesConfig = repeatableConfig.RewardScaling.Roubles;
var quest = GenerateRepeatableTemplate(
"Completion",
traderId,
repeatableConfig.Side,
sessionId
);
// Filter the items.json items to items the player must retrieve to complete quest: shouldn't be a quest item or "non-existent"
var itemsToRetrievePool = GetItemsToRetrievePool(
completionConfig,
repeatableConfig.RewardBlacklist
);
// Be fair, don't value the items be more expensive than the reward
var multiplier = _randomUtil.GetDouble(0.5, 1);
var roublesBudget = Math.Floor(
_mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) * multiplier
);
roublesBudget = Math.Max(roublesBudget, 5000d);
var itemSelection = itemsToRetrievePool
.Where(itemTpl => _itemHelper.GetItemPrice(itemTpl) < roublesBudget)
.ToList();
// We also have the option to use whitelist and/or blacklist which is defined in repeatableQuests.json as
// [{"minPlayerLevel": 1, "itemIds": ["id1",...]}, {"minPlayerLevel": 15, "itemIds": ["id3",...]}]
if (repeatableConfig.QuestConfig.Completion.UseWhitelist)
{
var itemWhitelist = _databaseService
.GetTemplates()
.RepeatableQuests.Data.Completion.ItemsWhitelist;
// Filter and concatenate items according to current player level
var itemIdsWhitelisted = itemWhitelist
.Where(p => p.MinPlayerLevel <= pmcLevel)
.SelectMany(x => x.ItemIds)
.ToHashSet(); //.Aggregate((a, p) => a.Concat(p.ItemIds), []);
itemSelection = itemSelection
.Where(x =>
{
// Whitelist can contain item tpls and item base type ids
return itemIdsWhitelisted.Any(v => _itemHelper.IsOfBaseclass(x, v))
|| itemIdsWhitelisted.Contains(x);
})
.ToList();
// check if items are missing
// var flatList = itemSelection.reduce((a, il) => a.concat(il[0]), []);
// var missing = itemIdsWhitelisted.filter(l => !flatList.includes(l));
}
if (repeatableConfig.QuestConfig.Completion.UseBlacklist)
{
var itemBlacklist = _databaseService
.GetTemplates()
.RepeatableQuests.Data.Completion.ItemsBlacklist;
// Filter and concatenate the arrays according to current player level
var itemIdsBlacklisted = itemBlacklist
.Where(p => p.MinPlayerLevel <= pmcLevel)
.SelectMany(x => x.ItemIds)
.ToHashSet(); //.Aggregate(List<ItemsBlacklist> , (a, p) => a.Concat(p.ItemIds) );
itemSelection = itemSelection
.Where(x =>
{
return itemIdsBlacklisted.All(v => !_itemHelper.IsOfBaseclass(x, v))
|| !itemIdsBlacklisted.Contains(x);
})
.ToList();
}
// Filtering too harsh
if (!itemSelection.Any())
{
_logger.Error(
_localisationService.GetText(
"repeatable-completion_quest_whitelist_too_small_or_blacklist_too_restrictive"
)
);
return null;
}
// Store the indexes of items we are asking player to supply
var distinctItemsToRetrieveCount = _randomUtil.GetInt(1, completionConfig.UniqueItemCount);
var chosenRequirementItemsTpls = new List<string>();
var usedItemIndexes = new HashSet<int>();
for (var i = 0; i < distinctItemsToRetrieveCount; i++)
{
var chosenItemIndex = _randomUtil.RandInt(itemSelection.Count);
var found = false;
for (var j = 0; j < _maxRandomNumberAttempts; j++)
{
if (usedItemIndexes.Contains(chosenItemIndex))
{
chosenItemIndex = _randomUtil.RandInt(itemSelection.Count);
}
else
{
found = true;
break;
}
}
if (!found)
{
_logger.Error(
_localisationService.GetText(
"repeatable-no_reward_item_found_in_price_range",
new { minPrice = 0, roublesBudget }
)
);
return null;
}
// Store index of item we've already chosen for later checking
usedItemIndexes.Add(chosenItemIndex);
var tplChosen = itemSelection[chosenItemIndex];
var itemPrice = _itemHelper.GetItemPrice(tplChosen).Value;
var minValue = completionConfig.MinimumRequestedAmount;
var maxValue = completionConfig.MaximumRequestedAmount;
var value = minValue;
// Get the value range within budget
var x = (int)Math.Floor(roublesBudget / itemPrice);
maxValue = Math.Min(maxValue, x);
if (maxValue > minValue)
// If it doesn't blow the budget we have for the request, draw a random amount of the selected
// Item type to be requested
{
value = _randomUtil.RandInt(minValue, maxValue + 1);
}
roublesBudget -= value * itemPrice;
// Push a CompletionCondition with the item and the amount of the item into quest
chosenRequirementItemsTpls.Add(tplChosen);
quest.Conditions.AvailableForFinish.Add(
GenerateCompletionAvailableForFinish(
tplChosen,
value,
repeatableConfig.QuestConfig.Completion
)
);
// Is there budget left for more items
if (roublesBudget > 0)
{
// Reduce item pool to fit budget
itemSelection = itemSelection
.Where(tpl => _itemHelper.GetItemPrice(tpl) < roublesBudget)
.ToList();
if (!itemSelection.Any())
{
// Nothing fits new budget, exit
break;
}
}
else
{
break;
}
}
quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward(
pmcLevel,
1,
traderId,
repeatableConfig,
completionConfig,
chosenRequirementItemsTpls
);
return quest;
}
/// <summary>
/// Generate a pool of item tpls the player should reasonably be able to retrieve
/// </summary>
/// <param name="completionConfig">Completion quest type config</param>
/// <param name="itemTplBlacklist">Item tpls to not add to pool</param>
/// <returns>Set of item tpls</returns>
protected HashSet<string> GetItemsToRetrievePool(
Completion completionConfig,
HashSet<string> itemTplBlacklist
)
{
// Get seasonal items that should not be added to pool as seasonal event is not active
var seasonalItems = _seasonalEventService.GetInactiveSeasonalEventItems();
// Check for specific base classes which don't make sense as reward item
// also check if the price is greater than 0; there are some items whose price can not be found
return _databaseService
.GetItems()
.Values.Where(itemTemplate =>
{
// Base "Item" item has no parent, ignore it
if (itemTemplate.Parent == string.Empty)
{
return false;
}
if (seasonalItems.Contains(itemTemplate.Id))
{
return false;
}
// Valid reward items share same logic as items to retrieve
return _repeatableQuestRewardGenerator.IsValidRewardItem(
itemTemplate.Id,
itemTplBlacklist,
completionConfig.RequiredItemTypeBlacklist
);
})
.Select(item => item.Id)
.ToHashSet();
}
/// <summary>
/// A repeatable quest, besides some more or less static components, exists of reward and condition (see
/// assets/database/templates/repeatableQuests.json)
/// This is a helper method for GenerateCompletionQuest to create a completion condition (of which a completion quest
/// theoretically can have many)
/// </summary>
/// <param name="itemTpl">Id of the item to request</param>
/// <param name="value">Amount of items of this specific type to request</param>
/// <param name="completionConfig">Completion config from quest.json</param>
/// <returns>object of "Completion"-condition</returns>
protected QuestCondition GenerateCompletionAvailableForFinish(
string itemTpl,
double value,
Completion completionConfig
)
{
var onlyFoundInRaid = completionConfig.RequiredItemsAreFiR;
var minDurability = _itemHelper.IsOfBaseclasses(
itemTpl,
[BaseClasses.WEAPON, BaseClasses.ARMOR]
)
? _randomUtil.GetArrayValue(
[
completionConfig.RequiredItemMinDurabilityMinMax.Min,
completionConfig.RequiredItemMinDurabilityMinMax.Max,
]
)
: 0;
// Dog tags MUST NOT be FiR for them to work
if (_itemHelper.IsDogtag(itemTpl))
{
onlyFoundInRaid = false;
}
return new QuestCondition
{
Id = _hashUtil.Generate(),
Index = 0,
ParentId = "",
DynamicLocale = true,
VisibilityConditions = [],
Target = new ListOrT<string>([itemTpl], null),
Value = value,
MinDurability = minDurability,
MaxDurability = 100,
DogtagLevel = 0,
OnlyFoundInRaid = onlyFoundInRaid,
IsEncoded = false,
ConditionType = "HandoverItem",
};
}
/// <summary>
/// Generates a valid Exploration quest
/// </summary>
@@ -938,8 +660,8 @@ public class RepeatableQuestGenerator(
: explorationConfig.MaximumExtracts + 1;
var numExtracts = _randomUtil.RandInt(1, exitTimesMax);
var quest = GenerateRepeatableTemplate(
"Exploration",
var quest = _repeatableQuestHelper.GenerateRepeatableTemplate(
RepeatableQuestType.Exploration,
traderId,
repeatableConfig.Side,
sessionId
@@ -1048,8 +770,8 @@ public class RepeatableQuestGenerator(
{
var pickupConfig = repeatableConfig.QuestConfig.Pickup;
var quest = GenerateRepeatableTemplate(
"Pickup",
var quest = _repeatableQuestHelper.GenerateRepeatableTemplate(
RepeatableQuestType.Pickup,
traderId,
repeatableConfig.Side,
sessionId
@@ -1124,104 +846,4 @@ public class RepeatableQuestGenerator(
ConditionType = "ExitName",
};
}
/// <summary>
/// Generates the base object of quest type format given as templates in
/// assets/database/templates/repeatableQuests.json
/// The templates include Elimination, Completion and Extraction quest types
/// </summary>
/// <param name="type">Quest type: "Elimination", "Completion" or "Extraction"</param>
/// <param name="traderId">Trader from which the quest will be provided</param>
/// <param name="playerGroup">Scav daily or pmc daily/weekly quest</param>
/// <returns>
/// Object which contains the base elements for repeatable quests of the requests type
/// (needs to be filled with reward and conditions by called to make a valid quest)
/// </returns>
protected RepeatableQuest GenerateRepeatableTemplate(
string type,
string traderId,
PlayerGroup playerGroup,
string sessionId
)
{
RepeatableQuest questData = null;
switch (type)
{
case "Elimination":
questData = _databaseService.GetTemplates().RepeatableQuests.Templates.Elimination;
break;
case "Completion":
questData = _databaseService.GetTemplates().RepeatableQuests.Templates.Completion;
break;
case "Exploration":
questData = _databaseService.GetTemplates().RepeatableQuests.Templates.Exploration;
break;
case "Pickup":
questData = _databaseService.GetTemplates().RepeatableQuests.Templates.Pickup;
break;
}
var questClone = _cloner.Clone(questData);
questClone.Id = _hashUtil.Generate();
questClone.TraderId = traderId;
/* in locale, these id correspond to the text of quests
template ids -pmc : Elimination = 616052ea3054fc0e2c24ce6e / Completion = 61604635c725987e815b1a46 / Exploration = 616041eb031af660100c9967
template ids -scav : Elimination = 62825ef60e88d037dc1eb428 / Completion = 628f588ebb558574b2260fe5 / Exploration = 62825ef60e88d037dc1eb42c
*/
// Get template id from config based on side and type of quest
var typeIds = _repeatableQuestHelper.GetRepeatableQuestTemplatesByGroup(playerGroup);
questClone.TemplateId = typeIds[type];
// Force REF templates to use prapors ID - solves missing text issue
var desiredTraderId = traderId == Traders.REF ? Traders.PRAPOR : traderId;
questClone.Name = questClone
.Name.Replace("{traderId}", traderId)
.Replace("{templateId}", questClone.TemplateId);
questClone.Note = questClone
.Note.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questClone.TemplateId);
questClone.Description = questClone
.Description.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questClone.TemplateId);
questClone.SuccessMessageText = questClone
.SuccessMessageText.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questClone.TemplateId);
questClone.FailMessageText = questClone
.FailMessageText.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questClone.TemplateId);
questClone.StartedMessageText = questClone
.StartedMessageText.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questClone.TemplateId);
questClone.ChangeQuestMessageText = questClone
.ChangeQuestMessageText.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questClone.TemplateId);
questClone.AcceptPlayerMessage = questClone
.AcceptPlayerMessage.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questClone.TemplateId);
questClone.DeclinePlayerMessage = questClone
.DeclinePlayerMessage.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questClone.TemplateId);
questClone.CompletePlayerMessage = questClone
.CompletePlayerMessage.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questClone.TemplateId);
questClone.QuestStatus.Id = _hashUtil.Generate();
questClone.QuestStatus.Uid = sessionId; // Needs to match user id
questClone.QuestStatus.QId = questClone.Id; // Needs to match quest id
return questClone;
}
}
@@ -1,18 +1,25 @@
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Models.Eft.Common.Tables;
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.Services;
using SPTarkov.Server.Core.Utils;
using SPTarkov.Server.Core.Utils.Cloners;
namespace SPTarkov.Server.Core.Helpers;
[Injectable]
public class RepeatableQuestHelper(
ISptLogger<RepeatableQuestHelper> _logger,
ConfigServer _configServer
ISptLogger<RepeatableQuestHelper> logger,
DatabaseService databaseService,
HashUtil hashUtil,
ICloner cloner,
ConfigServer configServer
)
{
protected QuestConfig _questConfig = _configServer.GetConfig<QuestConfig>();
protected QuestConfig _questConfig = configServer.GetConfig<QuestConfig>();
/// <summary>
/// Get the relevant elimination config based on the current players PMC level
@@ -36,7 +43,7 @@ public class RepeatableQuestHelper(
/// <param name="playerGroup">Side to get the templates for</param>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public Dictionary<string, string>? GetRepeatableQuestTemplatesByGroup(PlayerGroup playerGroup)
public Dictionary<string, string> GetRepeatableQuestTemplatesByGroup(PlayerGroup playerGroup)
{
var templates = _questConfig.RepeatableQuestTemplates;
@@ -47,4 +54,145 @@ public class RepeatableQuestHelper(
_ => throw new ArgumentOutOfRangeException(nameof(playerGroup), playerGroup, null),
};
}
/// <summary>
/// Gets a cloned repeatable quest template for the provided type with a unique id
/// </summary>
/// <param name="type">Type of template to retrieve</param>
/// <param name="traderId">TraderId that should provide this quest</param>
/// <returns>Cloned quest template</returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public RepeatableQuest? GetClonedQuestTemplateForType(
RepeatableQuestType type,
string traderId
)
{
var quest = type switch
{
RepeatableQuestType.Elimination => cloner.Clone(
databaseService.GetTemplates().RepeatableQuests?.Templates?.Elimination),
RepeatableQuestType.Completion => cloner.Clone(
databaseService.GetTemplates().RepeatableQuests?.Templates?.Completion),
RepeatableQuestType.Exploration => cloner.Clone(
databaseService.GetTemplates().RepeatableQuests?.Templates?.Exploration),
RepeatableQuestType.Pickup => cloner.Clone(
databaseService.GetTemplates().RepeatableQuests?.Templates?.Pickup),
_ => null
};
if (quest is null)
{
return null;
}
quest.Id = hashUtil.Generate();
quest.TraderId = traderId;
return quest;
}
/// <summary>
/// Generates the base object of quest type format given as templates in
/// assets/database/templates/repeatableQuests.json
/// The templates include Elimination, Completion and Extraction quest types
/// </summary>
/// <param name="type">Quest type: "Elimination", "Completion" or "Extraction"</param>
/// <param name="traderId">Trader from which the quest will be provided</param>
/// <param name="playerGroup">Scav daily or pmc daily/weekly quest</param>
/// <param name="sessionId">sessionId to generate template for</param>
/// <returns>
/// Object which contains the base elements for repeatable quests of the requests type
/// (needs to be filled with reward and conditions by called to make a valid quest)
/// </returns>
public RepeatableQuest? GenerateRepeatableTemplate(
RepeatableQuestType type,
string traderId,
PlayerGroup playerGroup,
string sessionId
)
{
var questData = GetClonedQuestTemplateForType(type, traderId);
if (questData is null)
{
// TODO: Localize me!
logger.Error($"No repeatable quest template found for type {type}");
return null;
}
// Get template id from config based on side and type of quest
var typeIds = GetRepeatableQuestTemplatesByGroup(playerGroup);
var templateName = Enum.GetName(type);
if (templateName is null)
{
// TODO: Localize me!
logger.Error($"Could not resolve template name for {type}");
return null;
}
questData.TemplateId = typeIds[templateName];
// Force REF templates to use prapors ID - solves missing text issue
var desiredTraderId = traderId == Traders.REF ? Traders.PRAPOR : traderId;
/* in locale, these id correspond to the text of quests
template ids -pmc : Elimination = 616052ea3054fc0e2c24ce6e / Completion = 61604635c725987e815b1a46 / Exploration = 616041eb031af660100c9967
template ids -scav : Elimination = 62825ef60e88d037dc1eb428 / Completion = 628f588ebb558574b2260fe5 / Exploration = 62825ef60e88d037dc1eb42c
*/
questData.Name = questData
.Name.Replace("{traderId}", traderId)
.Replace("{templateId}", questData.TemplateId);
questData.Note = questData
.Note?.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questData.TemplateId);
questData.Description = questData
.Description.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questData.TemplateId);
questData.SuccessMessageText = questData
.SuccessMessageText?.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questData.TemplateId);
questData.FailMessageText = questData
.FailMessageText?.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questData.TemplateId);
questData.StartedMessageText = questData
.StartedMessageText?.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questData.TemplateId);
questData.ChangeQuestMessageText = questData
.ChangeQuestMessageText?.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questData.TemplateId);
questData.AcceptPlayerMessage = questData
.AcceptPlayerMessage?.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questData.TemplateId);
questData.DeclinePlayerMessage = questData
.DeclinePlayerMessage?.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questData.TemplateId);
questData.CompletePlayerMessage = questData
.CompletePlayerMessage?.Replace("{traderId}", desiredTraderId)
.Replace("{templateId}", questData.TemplateId);
if (questData.QuestStatus is null)
{
// TODO: Localize me!
logger.Error($"No quest status found for type {type}");
return null;
}
questData.QuestStatus.Id = hashUtil.Generate();
questData.QuestStatus.Uid = sessionId; // Needs to match user id
questData.QuestStatus.QId = questData.Id; // Needs to match quest id
return questData;
}
}
@@ -0,0 +1,12 @@
using SPTarkov.Server.Core.Utils.Json.Converters;
namespace SPTarkov.Server.Core.Models.Enums;
[EftEnumConverter]
public enum RepeatableQuestType
{
Elimination,
Completion,
Exploration,
Pickup
}