This commit is contained in:
CWX
2025-01-22 22:04:54 +00:00
4 changed files with 348 additions and 13 deletions
@@ -12,6 +12,8 @@ using Core.Services;
using Core.Utils.Collections;
using SptCommon.Extensions;
using BodyPart = Core.Models.Spt.Config.BodyPart;
using Core.Models.Eft.Hideout;
using Core.Utils.Cloners;
namespace Core.Generators;
@@ -25,10 +27,13 @@ public class RepeatableQuestGenerator(
ItemHelper _itemHelper,
RepeatableQuestRewardGenerator _repeatableQuestRewardGenerator,
DatabaseService _databaseService,
ConfigServer _configServer
LocalisationService _localisationService,
ConfigServer _configServer,
ICloner _cloner
)
{
protected QuestConfig _questConfig = _configServer.GetConfig<QuestConfig>();
protected int _maxRandomNumberAttempts = 6;
/// <summary>
/// This method is called by /GetClientRepeatableQuests/ and creates one element of quest type format (see assets/database/templates/repeatableQuests.json).
@@ -130,7 +135,8 @@ public class RepeatableQuestGenerator(
var maxKillDifficulty = eliminationConfig.MaxKills;
var targetPool = questTypePool.Pool.Elimination;
targetsConfig = (ProbabilityObjectArray<Target, string, BossInfo>)targetsConfig.Where((x) => questTypePool.Pool.Elimination.Targets.ContainsKey(x.Key));
targetsConfig = targetsConfig.Filter((x) => questTypePool.Pool.Elimination.Targets.ContainsKey(x.Key));
if (targetsConfig.Count == 0 || targetsConfig.All((x) => x.Data.IsBoss.GetValueOrDefault(false)))
{
// There are no more targets left for elimination; delete it as a possible quest type
@@ -405,7 +411,13 @@ public class RepeatableQuestGenerator(
/// <returns>Elimination-location-subcondition object</returns>
protected QuestConditionCounterCondition GenerateEliminationLocation(List<string> location)
{
throw new NotImplementedException();
return new QuestConditionCounterCondition
{
Id = _hashUtil.Generate(),
DynamicLocale = true,
Target = location,
ConditionType = "Location"
};
}
/// <summary>
@@ -478,14 +490,156 @@ public class RepeatableQuestGenerator(
/// <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(
protected RepeatableQuest? GenerateCompletionQuest(
string sessionId,
int pmcLevel,
string traderId,
RepeatableQuestConfig repeatableConfig
)
{
throw new NotImplementedException();
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-existant"
var possibleItemsToRetrievePool = _repeatableQuestRewardGenerator.GetRewardableItems(
repeatableConfig,
traderId);
// Be fair, don't var the items be more expensive than the reward
var multi = _randomUtil.GetFloat((float)0.5, 1);
var roublesBudget = Math.Floor(
(double)(_mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) * multi));
roublesBudget = Math.Max(roublesBudget, 5000d);
var itemSelection = possibleItemsToRetrievePool.Where(
(x) => _itemHelper.GetItemPrice(x.Key) < 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.GetValueOrDefault(false)) {
var itemWhitelist = _databaseService.GetTemplates().RepeatableQuests.Data.Completion.ItemsWhitelist;
// Filter and concatenate the arrays according to current player level
var itemIdsWhitelisted = itemWhitelist
.Where((p) => p.MinPlayerLevel <= pmcLevel)
.SelectMany(x => x.ItemIds).ToList(); //.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.Key, v)) ||
itemIdsWhitelisted.Contains(x.Key)
);
}).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.GetValueOrDefault(false)) {
var itemBlacklist = _databaseService.GetTemplates().RepeatableQuests.Data.Completion.ItemsBlacklist;
// we filter and concatenate the arrays according to current player level
var itemIdsBlacklisted = itemBlacklist
.Where((p) => p.MinPlayerLevel <= pmcLevel)
.SelectMany(x => x.ItemIds).ToList(); //.Aggregate(List<ItemsBlacklist> , (a, p) => a.Concat(p.ItemIds) );
itemSelection = itemSelection.Where((x) => {
return (
itemIdsBlacklisted.All((v) => !_itemHelper.IsOfBaseclass(x.Key, v)) ||
!itemIdsBlacklisted.Contains(x.Key)
);
}).ToList();
}
if (!itemSelection.Any()) {
_logger.Error(_localisationService.GetText("repeatable-completion_quest_whitelist_too_small_or_blacklist_too_restrictive"));
return null;
}
// Draw items to ask player to retrieve
var isAmmo = 0;
// Store the indexes of items we are asking player to provide
var distinctItemsToRetrieveCount = _randomUtil.GetInt(1, completionConfig.UniqueItemCount.Value);
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 = roublesBudget }));
return null;
}
usedItemIndexes.Add(chosenItemIndex);
var itemSelected = itemSelection[chosenItemIndex];
var itemUnitPrice = _itemHelper.GetItemPrice(itemSelected.Key).Value;
var minValue = (double)completionConfig.MinimumRequestedAmount.Value;
var maxValue = (double)completionConfig.MaximumRequestedAmount.Value;
if (_itemHelper.IsOfBaseclass(itemSelected.Key, BaseClasses.AMMO)) {
// Prevent multiple ammo requirements from being picked
if (isAmmo > 0 && isAmmo < _maxRandomNumberAttempts) {
isAmmo++;
i--;
continue;
}
isAmmo++;
minValue = (double)completionConfig.MinimumRequestedBulletAmount.Value;
maxValue = (double)completionConfig.MaximumRequestedBulletAmount.Value;
}
var value = minValue;
// Get the value range within budget
var x = Math.Floor(roublesBudget / itemUnitPrice);
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((int)minValue, (int)maxValue + 1);
}
roublesBudget -= value * itemUnitPrice;
// Push a CompletionCondition with the item and the amount of the item
chosenRequirementItemsTpls.Add(itemSelected.Key);
quest.Conditions.AvailableForFinish.Add(GenerateCompletionAvailableForFinish(itemSelected.Key, value));
if (roublesBudget > 0) {
// Reduce the list possible items to fulfill the new budget constraint
itemSelection = itemSelection.Where((dbItem) => _itemHelper.GetItemPrice(dbItem.Key) < roublesBudget).ToList();
if (!itemSelection.Any()) {
break;
}
} else {
break;
}
}
quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward(
pmcLevel,
1,
traderId,
repeatableConfig,
completionConfig,
chosenRequirementItemsTpls);
return quest;
}
/// <summary>
@@ -495,7 +649,7 @@ public class RepeatableQuestGenerator(
/// <param name="itemTpl">id of the item to request</param>
/// <param name="value">amount of items of this specific type to request</param>
/// <returns>object of "Completion"-condition</returns>
protected RepeatableQuest GenerateCompletionAvailableForFinish(string itemTpl, int value)
protected QuestCondition GenerateCompletionAvailableForFinish(string itemTpl, double value)
{
throw new NotImplementedException();
}
@@ -509,14 +663,99 @@ public class RepeatableQuestGenerator(
/// <param name="questTypePool">Pools for quests (used to avoid redundant quests)</param>
/// <param name="repeatableConfig">The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig for the requested quest</param>
/// <returns>object of quest type format for "Exploration" (see assets/database/templates/repeatableQuests.json)</returns>
protected RepeatableQuest GenerateExplorationQuest(
protected RepeatableQuest? GenerateExplorationQuest(
string sessionId,
int pmcLevel,
string traderId,
QuestTypePool questTypePool,
RepeatableQuestConfig repeatableConfig)
{
throw new NotImplementedException();
var explorationConfig = repeatableConfig.QuestConfig.Exploration;
var requiresSpecificExtract =
_randomUtil.Random.Next() < repeatableConfig.QuestConfig.Exploration.SpecificExits.Probability;
if (questTypePool.Pool.Exploration.Locations.Count == 0)
{
// there are no more locations left for exploration; delete it as a possible quest type
questTypePool.Types = questTypePool.Types.Where((t) => t != "Exploration").ToList();
return null;
}
// If location drawn is factory, it's possible to either get factory4_day and factory4_night or only one
// of the both
var locationKey = _randomUtil.DrawRandomFromDict(questTypePool.Pool.Exploration.Locations)[0];
var locationTarget = questTypePool.Pool.Exploration.Locations[locationKey];
// Remove the location from the available pool
questTypePool.Pool.Exploration.Locations.Remove(locationKey);
// Different max extract count when specific extract needed
var exitTimesMax = requiresSpecificExtract
? explorationConfig.MaximumExtractsWithSpecificExit
: explorationConfig.MaximumExtracts + 1;
var numExtracts = _randomUtil.RandInt(1, exitTimesMax);
var quest = GenerateRepeatableTemplate("Exploration", traderId, repeatableConfig.Side, sessionId);
var exitStatusCondition = new QuestConditionCounterCondition{
Id = _hashUtil.Generate(),
DynamicLocale = true,
Status = ["Survived"],
ConditionType = "ExitStatus",
};
var locationCondition = new QuestConditionCounterCondition{
Id = _hashUtil.Generate(),
DynamicLocale = true,
Target = locationTarget,
ConditionType = "Location",
};
quest.Conditions.AvailableForFinish[0].Counter.Id = _hashUtil.Generate();
quest.Conditions.AvailableForFinish[0].Counter.Conditions = [exitStatusCondition, locationCondition];
quest.Conditions.AvailableForFinish[0].Value = numExtracts;
quest.Conditions.AvailableForFinish[0].Id = _hashUtil.Generate();
quest.Location = GetQuestLocationByMapId(locationKey.ToString());
if (requiresSpecificExtract)
{
// Fetch extracts for the requested side
var mapExits = GetLocationExitsForSide(locationKey.ToString(), repeatableConfig.Side);
// Only get exits that have a greater than 0% chance to spawn
var exitPool = mapExits.Where((exit) => exit.Chance > 0).ToList();
// Exclude exits with a requirement to leave (e.g. car extracts)
var possibleExits = exitPool.Where(
(exit) =>
exit.PassageRequirement is not null ||
repeatableConfig.QuestConfig.Exploration.SpecificExits.PassageRequirementWhitelist.Contains("PassageRequirement")).ToList();
if (possibleExits.Count == 0)
{
_logger.Error("Unable to choose specific exit on map: ${ locationKey}, Possible exit pool was empty");
}
else
{
// Choose one of the exits we filtered above
var chosenExit = _randomUtil.DrawRandomFromList(possibleExits, 1)[0];
// Create a quest condition to leave raid via chosen exit
var exitCondition = GenerateExplorationExitCondition(chosenExit);
quest.Conditions.AvailableForFinish[0].Counter.Conditions.Add(exitCondition);
}
}
// Difficulty for exploration goes from 1 extract to maxExtracts
// Difficulty for reward goes from 0.2...1 -> map
var difficulty = _mathUtil.MapToRange(numExtracts, 1, explorationConfig.MaximumExtracts.Value, 0.2, 1);
quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward(
pmcLevel,
difficulty,
traderId,
repeatableConfig,
explorationConfig);
return quest;
}
/// <summary>
@@ -576,6 +815,90 @@ public class RepeatableQuestGenerator(
string side,
string sessionId)
{
throw new NotImplementedException();
Quest 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 = side.ToLower() == "pmc" ? _questConfig.QuestTemplateIds.Pmc : _questConfig.QuestTemplateIds.Scav;
var templateId = string.Empty;
switch (type)
{
case "Completion":
templateId = typeIds.Completion;
break;
case "Elimination":
templateId = typeIds.Elimination;
break;
case "Exploration":
templateId = typeIds.Exploration;
break;
case "Pickup":
templateId = typeIds.Pickup;
break;
}
questClone.TemplateId = templateId;
// 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 (RepeatableQuest)questClone;
}
}
@@ -60,7 +60,7 @@ public class RepeatableQuestRewardGenerator(
double difficulty,
string traderId,
RepeatableQuestConfig repeatableConfig,
EliminationConfig eliminationConfig,
BaseQuestConfig eliminationConfig,
List<string>? rewardTplBlacklist = null)
{
// Get vars to configure rewards with
@@ -1,5 +1,6 @@
using Core.Utils.Cloners;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components.Web;
namespace Core.Utils.Collections;
@@ -46,6 +47,17 @@ public class ProbabilityObjectArray<T, K, V> : List<T> where T : ProbabilityObje
return probCumsum;
}
public ProbabilityObjectArray<T, K, V> Filter(Predicate<ProbabilityObject<K, V>> predicate)
{
var filtered = new ProbabilityObjectArray<T, K, V>(_mathUtil, _cloner, new List<T>());
foreach (var probabilityObject in this)
{
if (predicate.Invoke(probabilityObject))
filtered.Add(probabilityObject);
}
return filtered;
}
/**
* Clone this ProbabilitObjectArray
* @returns {ProbabilityObjectArray} Deep Copy of this ProbabilityObjectArray
+3 -3
View File
@@ -190,7 +190,7 @@ public class ItemTplGenerator(
// Include any bracketed suffixes that exist, handles the case of colored gun variants
var weaponFullName = _localeService.GetLocaleDb()[$"{kv.Key} Name"]?.ToUpper();
if (weaponFullName.RegexMatch("\\((.+?)\\)$", out var itemNameBracketSuffix) &&
if (weaponFullName.RegexMatch(@"\((.+?)\)$", out var itemNameBracketSuffix) &&
!weaponShortName.EndsWith(itemNameBracketSuffix.Groups[1].Value))
{
weaponShortName += $"_{itemNameBracketSuffix.Groups[1].Value}";
@@ -495,7 +495,7 @@ public class ItemTplGenerator(
var caliber = CleanCaliber(item.Properties.AmmoCaliber.ToUpper());
// If the item has a bracketed section at the end of its name, include that
if (itemName?.RegexMatch("\\((.+?)\\)$", out var itemNameBracketSuffix) ?? false)
if (itemName?.RegexMatch(@"\((.+?)\)$", out var itemNameBracketSuffix) ?? false)
{
return $"{caliber}_{itemNameBracketSuffix.Groups[1].Value}";
}
@@ -510,7 +510,7 @@ public class ItemTplGenerator(
}
// If the item has a bracketed section at the end of its name, use that
if (itemName.RegexMatch("\\((.+?)\\)$", out var itemNameBracker))
if (itemName.RegexMatch(@"\((.+?)\)$", out var itemNameBracker))
{
return itemNameBracker.Groups[1].Value;
}