Break rest of repeatable quest generation code into components. Fix nullability of exploration generation and improve error handling, make new helper method, add pick random quest type method to controller (#419)

This commit is contained in:
Cj
2025-06-23 05:03:56 -04:00
committed by GitHub
parent 6820d7b8be
commit b3dca61ac0
10 changed files with 600 additions and 390 deletions
@@ -395,7 +395,7 @@
"maxExtractsWithSpecificExit": 2,
"possibleSkillRewards": ["Endurance", "Strength", "Vitality"],
"specificExits": {
"probability": 0.2,
"chance": 20,
"passageRequirementWhitelist": [
"None",
"TransferItem",
@@ -1194,7 +1194,7 @@
"maxExtractsWithSpecificExit": 4,
"possibleSkillRewards": ["Endurance", "Strength", "Vitality"],
"specificExits": {
"probability": 0.1,
"chance": 10,
"passageRequirementWhitelist": [
"None",
"TransferItem",
@@ -1947,7 +1947,7 @@
"maxExtracts": 3,
"maxExtractsWithSpecificExit": 1,
"specificExits": {
"probability": 0.2,
"chance": 20,
"passageRequirementWhitelist": ["None", "WorldEvent", "Train", "Reference", "Empty"]
}
},
@@ -1,5 +1,6 @@
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Generators;
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;
@@ -24,6 +25,10 @@ namespace SPTarkov.Server.Core.Controllers;
[Injectable]
public class RepeatableQuestController(
ISptLogger<RepeatableQuestChangeRequest> _logger,
EliminationQuestGenerator _eliminationQuestGenerator,
CompletionQuestGenerator _completionQuestGenerator,
ExplorationQuestGenerator _explorationQuestGenerator,
PickupQuestGenerator _pickupQuestGenerator,
TimeUtil _timeUtil,
MathUtil _mathUtil,
RandomUtil _randomUtil,
@@ -33,7 +38,6 @@ public class RepeatableQuestController(
LocalisationService _localisationService,
EventOutputHolder _eventOutputHolder,
PaymentService _paymentService,
RepeatableQuestGenerator _repeatableQuestGenerator,
RepeatableQuestHelper _repeatableQuestHelper,
QuestHelper _questHelper,
DatabaseService _databaseService,
@@ -42,7 +46,7 @@ public class RepeatableQuestController(
)
{
protected static readonly List<string> _questTypes = ["PickUp", "Exploration", "Elimination"];
protected QuestConfig _questConfig = _configServer.GetConfig<QuestConfig>();
protected QuestConfig QuestConfig = _configServer.GetConfig<QuestConfig>();
/// <summary>
/// Handle the client accepting a repeatable quest and starting it
@@ -152,7 +156,7 @@ public class RepeatableQuestController(
repeatablesOfTypeInProfile.ChangeRequirement.Remove(changeRequest.QuestId);
// Get config for this repeatable subtype (daily/weekly/scav)
var repeatableConfig = _questConfig.RepeatableQuests.FirstOrDefault(config =>
var repeatableConfig = QuestConfig.RepeatableQuests.FirstOrDefault(config =>
config.Name == repeatablesOfTypeInProfile.Name
);
@@ -374,7 +378,7 @@ public class RepeatableQuestController(
var attempts = 0;
while (attempts < maxAttempts && questTypePool.Types.Count > 0)
{
newRepeatableQuest = _repeatableQuestGenerator.GenerateRepeatableQuest(
newRepeatableQuest = PickAndGenerateRandomRepeatableQuest(
sessionId,
pmcData.Info.Level.Value,
pmcData.TradersInfo,
@@ -404,6 +408,86 @@ public class RepeatableQuestController(
return newRepeatableQuest;
}
/// <summary>
/// This method is called by /GetClientRepeatableQuests/ and creates one element of quest type format (see
/// assets/database/templates/repeatableQuests.json).
/// It randomly draws a quest type (currently Elimination, Completion or Exploration) as well as a trader who is
/// providing the quest
/// </summary>
/// <param name="sessionId">Session id</param>
/// <param name="pmcLevel">Player's level for requested items and reward generation</param>
/// <param name="pmcTraderInfo">Players trader standing/rep levels</param>
/// <param name="questTypePool">Possible quest types pool</param>
/// <param name="repeatableConfig">Repeatable quest config</param>
/// <returns>RepeatableQuest</returns>
public RepeatableQuest? PickAndGenerateRandomRepeatableQuest(
string sessionId,
int pmcLevel,
Dictionary<string, TraderInfo> pmcTraderInfo,
QuestTypePool questTypePool,
RepeatableQuestConfig repeatableConfig
)
{
var questType = _randomUtil.DrawRandomFromList(questTypePool.Types).First();
// Get traders from whitelist and filter by quest type availability
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();
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" => _eliminationQuestGenerator.Generate(
sessionId,
pmcLevel,
traderId,
questTypePool,
repeatableConfig
),
"Completion" => _completionQuestGenerator.Generate(
sessionId,
pmcLevel,
traderId,
questTypePool,
repeatableConfig
),
"Exploration" => _explorationQuestGenerator.Generate(
sessionId,
pmcLevel,
traderId,
questTypePool,
repeatableConfig
),
"Pickup" => _pickupQuestGenerator.Generate(
sessionId,
pmcLevel,
traderId,
questTypePool,
repeatableConfig
),
_ => null,
};
}
/// <summary>
/// Remove the provided quest from pmc and scav character profiles
/// </summary>
@@ -486,7 +570,7 @@ public class RepeatableQuestController(
var currentTime = _timeUtil.GetTimeStamp();
// Daily / weekly / Daily_Savage
foreach (var repeatableConfig in _questConfig.RepeatableQuests)
foreach (var repeatableConfig in QuestConfig.RepeatableQuests)
{
// Get daily/weekly data from profile, add empty object if missing
var generatedRepeatables = GetRepeatableQuestSubTypeFromProfile(
@@ -544,7 +628,7 @@ public class RepeatableQuestController(
var lifeline = 0;
while (quest?.Id == null && questTypePool.Types.Count > 0)
{
quest = _repeatableQuestGenerator.GenerateRepeatableQuest(
quest = PickAndGenerateRandomRepeatableQuest(
sessionID,
pmcData.Info.Level ?? 0,
pmcData.TradersInfo,
@@ -848,7 +932,7 @@ public class RepeatableQuestController(
{
return new QuestTypePool
{
Types = _cloner.Clone(repeatableConfig.Types),
Types = _cloner.Clone(repeatableConfig.Types)!,
Pool = new QuestPool
{
Exploration = new ExplorationPool
@@ -3,6 +3,7 @@ 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.Spt.Repeatable;
using SPTarkov.Server.Core.Models.Utils;
using SPTarkov.Server.Core.Servers;
using SPTarkov.Server.Core.Services;
@@ -19,14 +20,17 @@ public class CompletionQuestGenerator(
DatabaseService databaseService,
SeasonalEventService seasonalEventService,
LocalisationService localisationService,
ConfigServer configServer,
RandomUtil randomUtil,
MathUtil mathUtil,
HashUtil hashUtil,
ItemHelper itemHelper
)
) : IRepeatableQuestGenerator
{
protected const int MaxRandomNumberAttempts = 6;
protected QuestConfig QuestConfig = configServer.GetConfig<QuestConfig>();
/// <summary>
/// Generates a valid Completion quest
/// </summary>
@@ -42,6 +46,7 @@ public class CompletionQuestGenerator(
string sessionId,
int pmcLevel,
string traderId,
QuestTypePool questTypePool,
RepeatableQuestConfig repeatableConfig
)
{
@@ -1,7 +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;
using SPTarkov.Server.Core.Models.Enums;
using SPTarkov.Server.Core.Models.Spt.Config;
@@ -14,27 +12,24 @@ 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;
namespace SPTarkov.Server.Core.Generators.RepeatableQuestGeneration;
[Obsolete("In the process of being removed, do NOT add any new logic!!")]
// TODO: Refactor me!
[Injectable]
public class RepeatableQuestGenerator(
ISptLogger<RepeatableQuestGenerator> _logger,
RandomUtil _randomUtil,
HashUtil _hashUtil,
MathUtil _mathUtil,
RepeatableQuestHelper _repeatableQuestHelper,
ItemHelper _itemHelper,
RepeatableQuestRewardGenerator _repeatableQuestRewardGenerator,
DatabaseService _databaseService,
LocalisationService _localisationService,
ConfigServer _configServer,
ICloner _cloner,
// This is temporary while this is being refactored, eventually these will all live in the RepeatableQuestController.
CompletionQuestGenerator _completionQuestGenerator
)
public class EliminationQuestGenerator(
ISptLogger<EliminationQuestGenerator> logger,
RandomUtil randomUtil,
HashUtil hashUtil,
MathUtil mathUtil,
RepeatableQuestHelper repeatableQuestHelper,
ItemHelper itemHelper,
RepeatableQuestRewardGenerator repeatableQuestRewardGenerator,
DatabaseService databaseService,
LocalisationService localisationService,
ConfigServer configServer,
ICloner cloner
) : IRepeatableQuestGenerator
{
/// <summary>
/// Body parts to present to the client as opposed to the body part information in quest data.
@@ -47,87 +42,7 @@ public class RepeatableQuestGenerator(
{ BodyParts.Chest, [BodyParts.Chest, BodyParts.Stomach] },
};
protected int _maxRandomNumberAttempts = 6;
protected QuestConfig _questConfig = _configServer.GetConfig<QuestConfig>();
/// <summary>
/// This method is called by /GetClientRepeatableQuests/ and creates one element of quest type format (see
/// assets/database/templates/repeatableQuests.json).
/// It randomly draws a quest type (currently Elimination, Completion or Exploration) as well as a trader who is
/// providing the quest
/// </summary>
/// <param name="sessionId">Session id</param>
/// <param name="pmcLevel">Player's level for requested items and reward generation</param>
/// <param name="pmcTraderInfo">Players trader standing/rep levels</param>
/// <param name="questTypePool">Possible quest types pool</param>
/// <param name="repeatableConfig">Repeatable quest config</param>
/// <returns>RepeatableQuest</returns>
public RepeatableQuest? GenerateRepeatableQuest(
string sessionId,
int pmcLevel,
Dictionary<string, TraderInfo> pmcTraderInfo,
QuestTypePool questTypePool,
RepeatableQuestConfig repeatableConfig
)
{
var questType = _randomUtil.DrawRandomFromList(questTypePool.Types).First();
// Get traders from whitelist and filter by quest type availability
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();
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(
sessionId,
pmcLevel,
traderId,
questTypePool,
repeatableConfig
),
"Completion" => _completionQuestGenerator.Generate(
sessionId,
pmcLevel,
traderId,
repeatableConfig
),
"Exploration" => GenerateExplorationQuest(
sessionId,
pmcLevel,
traderId,
questTypePool,
repeatableConfig
),
"Pickup" => GeneratePickupQuest(
sessionId,
pmcLevel,
traderId,
questTypePool,
repeatableConfig
),
_ => null,
};
}
protected QuestConfig QuestConfig = configServer.GetConfig<QuestConfig>();
/// <summary>
/// Generate a randomised Elimination quest
@@ -138,10 +53,10 @@ 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 requestd quest
/// for the requested quest
/// </param>
/// <returns>Object of quest type format for "Elimination" (see assets/database/templates/repeatableQuests.json)</returns>
protected RepeatableQuest GenerateEliminationQuest(
public RepeatableQuest? Generate(
string sessionId,
int pmcLevel,
string traderId,
@@ -151,29 +66,29 @@ public class RepeatableQuestGenerator(
{
var rand = new Random();
var eliminationConfig = _repeatableQuestHelper.GetEliminationConfigByPmcLevel(
var eliminationConfig = repeatableQuestHelper.GetEliminationConfigByPmcLevel(
pmcLevel,
repeatableConfig
);
var locationsConfig = repeatableConfig.Locations;
var targetsConfig = new ProbabilityObjectArray<string, BossInfo>(
_mathUtil,
_cloner,
mathUtil,
cloner,
eliminationConfig.Targets
);
var bodyPartsConfig = new ProbabilityObjectArray<string, List<string>>(
_mathUtil,
_cloner,
mathUtil,
cloner,
eliminationConfig.BodyParts
);
var weaponCategoryRequirementConfig = new ProbabilityObjectArray<string, List<string>>(
_mathUtil,
_cloner,
mathUtil,
cloner,
eliminationConfig.WeaponCategoryRequirements
);
var weaponRequirementConfig = new ProbabilityObjectArray<string, List<string>>(
_mathUtil,
_cloner,
mathUtil,
cloner,
eliminationConfig.WeaponRequirements
);
@@ -233,7 +148,7 @@ public class RepeatableQuestGenerator(
if (
locations.Contains("any")
&& (
_randomUtil.GetChance100(eliminationConfig.SpecificLocationChance)
randomUtil.GetChance100(eliminationConfig.SpecificLocationChance)
|| locations.Count <= 1
)
)
@@ -248,7 +163,7 @@ public class RepeatableQuestGenerator(
if (locations.Count > 0)
{
// Get name of location we want elimination to occur on
locationKey = _randomUtil.DrawRandomFromList(locations).FirstOrDefault();
locationKey = randomUtil.DrawRandomFromList(locations).FirstOrDefault();
// Get a pool of locations the chosen bot type can be eliminated on
if (
@@ -258,7 +173,7 @@ public class RepeatableQuestGenerator(
)
)
{
_logger.Warning(
logger.Warning(
$"Bot to kill: {botTypeToEliminate} not found in elimination dict"
);
}
@@ -279,8 +194,8 @@ public class RepeatableQuestGenerator(
else
{
// Never should reach this if everything works out
_logger.Error(
_localisationService.GetText(
logger.Error(
localisationService.GetText(
"quest-repeatable_elimination_generation_failed_please_report"
)
);
@@ -290,13 +205,13 @@ public class RepeatableQuestGenerator(
// draw the target body part and calculate the difficulty factor
var bodyPartsToClient = new List<string>();
var bodyPartDifficulty = 0d;
if (_randomUtil.GetChance100(eliminationConfig.BodyPartChance))
if (randomUtil.GetChance100(eliminationConfig.BodyPartChance))
{
// if we add a bodyPart condition, we draw randomly one or two parts
// each bodyPart of the BODYPARTS ProbabilityObjectArray includes the string(s) which need to be presented to the client in ProbabilityObjectArray.data
// e.g. we draw "Arms" from the probability array but must present ["LeftArm", "RightArm"] to the client
bodyPartsToClient = [];
var bodyParts = bodyPartsConfig.Draw(_randomUtil.RandInt(1, 3), false);
var bodyParts = bodyPartsConfig.Draw(randomUtil.RandInt(1, 3), false);
double probability = 0;
foreach (var bodyPart in bodyParts)
{
@@ -326,7 +241,7 @@ public class RepeatableQuestGenerator(
if (targetsConfig.Data(botTypeToEliminate)?.IsBoss ?? false)
{
// Get all boss spawn information
var bossSpawns = _databaseService
var bossSpawns = databaseService
.GetLocations()
.GetDictionary()
.Select(x => x.Value)
@@ -349,7 +264,7 @@ public class RepeatableQuestGenerator(
}
if (
_randomUtil.GetChance100(eliminationConfig.DistanceProbability)
randomUtil.GetChance100(eliminationConfig.DistanceProbability)
&& isDistanceRequirementAllowed
)
{
@@ -368,7 +283,7 @@ public class RepeatableQuestGenerator(
}
string? allowedWeaponsCategory = null;
if (_randomUtil.GetChance100(eliminationConfig.WeaponCategoryRequirementProbability))
if (randomUtil.GetChance100(eliminationConfig.WeaponCategoryRequirementProbability))
{
// Filter out close range weapons from far distance requirement
if (distance > 50)
@@ -406,10 +321,10 @@ public class RepeatableQuestGenerator(
{
var weaponRequirement = weaponRequirementConfig.Draw(1, false);
var specificAllowedWeaponCategory = weaponRequirementConfig.Data(weaponRequirement[0]);
var allowedWeapons = _itemHelper.GetItemTplsOfBaseType(
var allowedWeapons = itemHelper.GetItemTplsOfBaseType(
specificAllowedWeaponCategory[0]
);
allowedWeapon = _randomUtil.GetArrayValue(allowedWeapons);
allowedWeapon = randomUtil.GetArrayValue(allowedWeapons);
}
// Draw how many npm kills are required
@@ -434,9 +349,9 @@ public class RepeatableQuestGenerator(
// Aforementioned issue makes it a bit crazy since now all easier quests give significantly lower rewards than Completion / Exploration
// I therefore moved the mapping a bit up (from 0.2...1 to 0.5...2) so that normal difficulty still gives good reward and having the
// 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 difficulty = mathUtil.MapToRange(curDifficulty, minDifficulty, maxDifficulty, 0.5, 2);
var quest = _repeatableQuestHelper.GenerateRepeatableTemplate(
var quest = repeatableQuestHelper.GenerateRepeatableTemplate(
RepeatableQuestType.Elimination,
traderId,
repeatableConfig.Side,
@@ -450,7 +365,7 @@ public class RepeatableQuestGenerator(
}
var availableForFinishCondition = quest.Conditions.AvailableForFinish[0];
availableForFinishCondition.Counter.Id = _hashUtil.Generate();
availableForFinishCondition.Counter.Id = hashUtil.Generate();
availableForFinishCondition.Counter.Conditions = [];
// Only add specific location condition if specific map selected
@@ -472,10 +387,12 @@ public class RepeatableQuestGenerator(
)
);
availableForFinishCondition.Value = desiredKillCount;
availableForFinishCondition.Id = _hashUtil.Generate();
quest.Location = GetQuestLocationByMapId(locationKey);
availableForFinishCondition.Id = hashUtil.Generate();
quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward(
// Get the quest location, default to any if none exist
quest.Location = repeatableQuestHelper.GetQuestLocationByMapId(locationKey) ?? "any";
quest.Rewards = repeatableQuestRewardGenerator.GenerateReward(
pmcLevel,
Math.Min(difficulty, 1),
traderId,
@@ -501,7 +418,7 @@ public class RepeatableQuestGenerator(
{
if (targetsConfig.Data(targetKey)?.IsBoss ?? false)
{
return _randomUtil.RandInt(
return randomUtil.RandInt(
eliminationConfig.MinBossKills,
eliminationConfig.MaxBossKills + 1
);
@@ -509,13 +426,13 @@ public class RepeatableQuestGenerator(
if (targetsConfig.Data(targetKey)?.IsPmc ?? false)
{
return _randomUtil.RandInt(
return randomUtil.RandInt(
eliminationConfig.MinPmcKills,
eliminationConfig.MaxPmcKills + 1
);
}
return _randomUtil.RandInt(eliminationConfig.MinKills, eliminationConfig.MaxKills + 1);
return randomUtil.RandInt(eliminationConfig.MinKills, eliminationConfig.MaxKills + 1);
}
protected double DifficultyWeighing(
@@ -540,7 +457,7 @@ public class RepeatableQuestGenerator(
{
return new QuestConditionCounterCondition
{
Id = _hashUtil.Generate(),
Id = hashUtil.Generate(),
DynamicLocale = true,
Target = new ListOrT<string>(location, null),
ConditionType = "Location",
@@ -566,7 +483,7 @@ public class RepeatableQuestGenerator(
{
var killConditionProps = new QuestConditionCounterCondition
{
Id = _hashUtil.Generate(),
Id = hashUtil.Generate(),
DynamicLocale = true,
Target = new ListOrT<string>(null, target), // e,g, "AnyPmc"
Value = 1,
@@ -613,239 +530,4 @@ public class RepeatableQuestGenerator(
return killConditionProps;
}
/// <summary>
/// Generates a valid Exploration quest
/// </summary>
/// <param name="sessionId">session id for the quest</param>
/// <param name="pmcLevel">player's level for reward generation</param>
/// <param name="traderId">trader from which the quest will be provided</param>
/// <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(
string sessionId,
int pmcLevel,
string traderId,
QuestTypePool questTypePool,
RepeatableQuestConfig repeatableConfig
)
{
var explorationConfig = repeatableConfig.QuestConfig.Exploration;
var requiresSpecificExtract =
_randomUtil.Random.NextDouble()
< 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 = _repeatableQuestHelper.GenerateRepeatableTemplate(
RepeatableQuestType.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 = new ListOrT<string>(locationTarget, null),
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)[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,
0.2,
1
);
quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward(
pmcLevel,
difficulty,
traderId,
repeatableConfig,
explorationConfig
);
return quest;
}
/// <summary>
/// Filter a maps exits to just those for the desired side
/// </summary>
/// <param name="locationKey">Map id (e.g. factory4_day)</param>
/// <param name="playerGroup">Pmc/Scav</param>
/// <returns>List of Exit objects</returns>
protected List<Exit> GetLocationExitsForSide(string locationKey, PlayerGroup playerGroup)
{
var mapExtracts = _databaseService.GetLocation(locationKey.ToLower()).AllExtracts;
return mapExtracts.Where(exit => exit.Side == Enum.GetName(playerGroup)).ToList();
}
protected RepeatableQuest GeneratePickupQuest(
string sessionId,
int pmcLevel,
string traderId,
QuestTypePool questTypePool,
RepeatableQuestConfig repeatableConfig
)
{
var pickupConfig = repeatableConfig.QuestConfig.Pickup;
var quest = _repeatableQuestHelper.GenerateRepeatableTemplate(
RepeatableQuestType.Pickup,
traderId,
repeatableConfig.Side,
sessionId
);
var itemTypeToFetchWithCount = _randomUtil.GetArrayValue(
pickupConfig.ItemTypeToFetchWithMaxCount
);
var itemCountToFetch = _randomUtil.RandInt(
itemTypeToFetchWithCount.MinimumPickupCount.Value,
itemTypeToFetchWithCount.MaximumPickupCount + 1
);
// Choose location - doesnt seem to work for anything other than 'any'
// var locationKey: string = this.randomUtil.drawRandomFromDict(questTypePool.pool.Pickup.locations)[0];
// var locationTarget = questTypePool.pool.Pickup.locations[locationKey];
var findCondition = quest.Conditions.AvailableForFinish.FirstOrDefault(x =>
x.ConditionType == "FindItem"
);
findCondition.Target = new ListOrT<string>([itemTypeToFetchWithCount.ItemType], null);
findCondition.Value = itemCountToFetch;
var counterCreatorCondition = quest.Conditions.AvailableForFinish.FirstOrDefault(x =>
x.ConditionType == "CounterCreator"
);
// var locationCondition = counterCreatorCondition._props.counter.conditions.find(x => x._parent === "Location");
// (locationCondition._props as ILocationConditionProps).target = [...locationTarget];
var equipmentCondition = counterCreatorCondition.Counter.Conditions.FirstOrDefault(x =>
x.ConditionType == "Equipment"
);
equipmentCondition.EquipmentInclusive =
[
[itemTypeToFetchWithCount.ItemType],
];
// Add rewards
quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward(
pmcLevel,
1,
traderId,
repeatableConfig,
pickupConfig
);
return quest;
}
/// <summary>
/// Convert a location into an quest code can read (e.g. factory4_day into 55f2d3fd4bdc2d5f408b4567)
/// </summary>
/// <param name="locationKey">e.g factory4_day</param>
/// <returns>guid</returns>
protected string GetQuestLocationByMapId(string locationKey)
{
return _questConfig.LocationIdMap[locationKey];
}
/// <summary>
/// Exploration repeatable quests can specify a required extraction point.
/// This method creates the according object which will be appended to the conditions list
/// </summary>
/// <param name="exit">The exit name to generate the condition for</param>
/// <returns>Exit condition</returns>
protected QuestConditionCounterCondition GenerateExplorationExitCondition(Exit exit)
{
return new QuestConditionCounterCondition
{
Id = _hashUtil.Generate(),
DynamicLocale = true,
ExitName = exit.Name,
ConditionType = "ExitName",
};
}
}
@@ -0,0 +1,319 @@
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Helpers;
using SPTarkov.Server.Core.Models.Eft.Common;
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.Spt.Repeatable;
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 ExplorationQuestGenerator(
ISptLogger<ExplorationQuestGenerator> logger,
RepeatableQuestHelper repeatableQuestHelper,
RepeatableQuestRewardGenerator repeatableQuestRewardGenerator,
DatabaseService databaseService,
LocalisationService localisationService,
ConfigServer configServer,
RandomUtil randomUtil,
MathUtil mathUtil,
HashUtil hashUtil
) : IRepeatableQuestGenerator
{
protected record LocationInfo(
ELocationName LocationName,
List<string> LocationTarget,
bool RequiresSpecificExtract,
int NumOfExtractsRequired
);
protected QuestConfig QuestConfig = configServer.GetConfig<QuestConfig>();
/// <summary>
/// Generates a valid Exploration quest
/// </summary>
/// <param name="sessionId">session id for the quest</param>
/// <param name="pmcLevel">player's level for reward generation</param>
/// <param name="traderId">trader from which the quest will be provided</param>
/// <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>
public RepeatableQuest? Generate(
string sessionId,
int pmcLevel,
string traderId,
QuestTypePool questTypePool,
RepeatableQuestConfig repeatableConfig
)
{
var explorationConfig = repeatableConfig.QuestConfig.Exploration;
// Try and get a location to generate for
if (!TryGetLocationInfo(repeatableConfig, explorationConfig, questTypePool, out var locationInfo)
|| locationInfo is null)
{
// TODO - Localize me
logger.Warning("Generating exploration repeatable quest failed, no remaining locations available");
return null;
}
// Generate the quest template
var quest = repeatableQuestHelper.GenerateRepeatableTemplate(
RepeatableQuestType.Exploration,
traderId,
repeatableConfig.Side,
sessionId
);
if (quest is null)
{
// TODO - Localize me
logger.Error("Generating quest failed, no quest template available");
return null;
}
// Generate the available for finish exit condition
if (!TryGenerateAvailableForFinish(quest, locationInfo))
{
// TODO - Localize me
logger.Error($"Generating AvailableForFinish failed for location {locationInfo.LocationName}");
return null;
}
// If we require a specific extract requirement, generate it
if (locationInfo.RequiresSpecificExtract
&& !TryGenerateSpecificExtractRequirement(quest, repeatableConfig, locationInfo))
{
// TODO - Localize me
logger.Error($"Generating SpecificExtractRequirement failed for location {locationInfo.LocationName}");
return null;
}
// Difficulty for exploration goes from 1 extract to maxExtracts
// Difficulty for reward goes from 0.2...1 -> map
var difficulty = mathUtil.MapToRange(
locationInfo.NumOfExtractsRequired,
1,
explorationConfig.MaximumExtracts,
0.2,
1
);
quest.Rewards = repeatableQuestRewardGenerator.GenerateReward(
pmcLevel,
difficulty,
traderId,
repeatableConfig,
explorationConfig
);
return quest;
}
/// <summary>
/// Draws a location from the exploration location pool
/// </summary>
/// <param name="repeatableConfig"></param>
/// <param name="explorationConfig"></param>
/// <param name="pool">Pool to draw from</param>
/// <param name="locationInfo">Location chosen</param>
/// <returns>True if location selected, false if no locations remain</returns>
protected bool TryGetLocationInfo(
RepeatableQuestConfig repeatableConfig,
Exploration explorationConfig,
QuestTypePool pool,
out LocationInfo? locationInfo)
{
if (pool.Pool?.Exploration?.Locations?.Count is null or 0)
{
// there are no more locations left for exploration; delete it as a possible quest type
pool.Types = pool.Types?.Where(t => t != "Exploration").ToList();
locationInfo = null;
return false;
}
// If location drawn is factory, it's possible to either get factory4_day and factory4_night use index 0,
// as the key is factory4_day
var locationKey = randomUtil.DrawRandomFromDict(pool.Pool.Exploration.Locations)[
0
];
// Make the location info object
var locationTarget = pool.Pool!.Exploration!.Locations![locationKey];
var requiresSpecificExtract =
randomUtil.GetChance100(repeatableConfig.QuestConfig.Exploration.SpecificExits.Chance);
var numExtracts = GetNumberOfExits(explorationConfig, requiresSpecificExtract);
locationInfo = new LocationInfo(locationKey, locationTarget.ToList(), requiresSpecificExtract, numExtracts);
// Remove the location from the available pool
pool.Pool.Exploration.Locations.Remove(locationKey);
return true;
}
/// <summary>
/// Get the number of times the player needs to exit
/// </summary>
/// <param name="config">Exploration config</param>
/// <param name="requiresSpecificExtract">Is this a specific extract</param>
/// <returns>Number of exit requirements</returns>
protected int GetNumberOfExits(Exploration config, bool requiresSpecificExtract)
{
// Different max extract count when specific extract needed
var exitTimesMax = requiresSpecificExtract
? config.MaximumExtractsWithSpecificExit
: config.MaximumExtracts + 1;
return randomUtil.RandInt(1, exitTimesMax);
}
/// <summary>
/// Filter a maps exits to just those for the desired side
/// </summary>
/// <param name="locationKey">Map id (e.g. factory4_day)</param>
/// <param name="playerGroup">Pmc/Scav</param>
/// <returns>List of Exit objects</returns>
protected List<Exit>? GetLocationExitsForSide(string locationKey, PlayerGroup playerGroup)
{
var mapExtracts = databaseService.GetLocation(locationKey.ToLower())?.AllExtracts;
return mapExtracts?.Where(exit => exit.Side == Enum.GetName(playerGroup)).ToList();
}
/// <summary>
/// Generate the initial available for finish condition
/// </summary>
/// <param name="quest">quest to add the condition to</param>
/// <param name="locationInfo">LocationInfo object with the generated data</param>
/// <returns>True if generated, false if not</returns>
protected bool TryGenerateAvailableForFinish(RepeatableQuest quest, LocationInfo locationInfo)
{
// This should never be hit, this is here to shut the compiler up.
if (quest.Conditions.AvailableForFinish?[0].Counter is null)
{
logger.Error("Counter is null, something has gone terribly wrong");
return false;
}
// Lookup the location
var location = repeatableQuestHelper.GetQuestLocationByMapId(locationInfo.LocationName.ToString());
if (location is null)
{
// TODO - Localize me
logger.Error($"Unable to get locationId for {locationInfo.LocationName}");
return false;
}
var exitStatusCondition = new QuestConditionCounterCondition
{
Id = hashUtil.Generate(),
DynamicLocale = true,
Status = ["Survived"],
ConditionType = "ExitStatus",
};
var locationCondition = new QuestConditionCounterCondition
{
Id = hashUtil.Generate(),
DynamicLocale = true,
Target = new ListOrT<string>(locationInfo.LocationTarget, null),
ConditionType = "Location",
};
quest.Conditions.AvailableForFinish![0].Counter!.Id = hashUtil.Generate();
quest.Conditions.AvailableForFinish![0].Counter!.Conditions =
[
exitStatusCondition,
locationCondition,
];
quest.Conditions.AvailableForFinish[0].Value = locationInfo.NumOfExtractsRequired;
quest.Conditions.AvailableForFinish[0].Id = hashUtil.Generate();
quest.Location = location;
return true;
}
/// <summary>
/// Adds a specific extract requirement to the quest
/// </summary>
/// <param name="quest">quest to add it to</param>
/// <param name="repeatableConfig">repeatable config</param>
/// <param name="locationInfo">LocationInfo object with the generated data</param>
/// <returns>True if generated, false if not</returns>
protected bool TryGenerateSpecificExtractRequirement(RepeatableQuest quest, RepeatableQuestConfig repeatableConfig, LocationInfo locationInfo)
{
// Fetch extracts for the requested side
var mapExits = GetLocationExitsForSide(locationInfo.LocationName.ToString(), repeatableConfig.Side);
if (mapExits is null)
{
// TODO: Localize me
logger.Error($"Unable to get location list for location {locationInfo.LocationName}");
return false;
}
// 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)
{
// TODO - Localize me!
logger.Error(
$"Unable to choose specific exit on map: {locationInfo.LocationName}, Possible exit pool was empty"
);
return false;
}
// Choose one of the exits we filtered above
var chosenExit = randomUtil.DrawRandomFromList(possibleExits)[0];
// Create a quest condition to leave raid via chosen exit
var exitCondition = GenerateQuestConditionCounter(chosenExit);
quest.Conditions.AvailableForFinish![0].Counter!.Conditions!.Add(exitCondition);
return true;
}
/// <summary>
/// Exploration repeatable quests can specify a required extraction point.
/// This method creates the according object which will be appended to the conditions list
/// </summary>
/// <param name="exit">The exit name to generate the condition for</param>
/// <returns>Exit condition</returns>
protected QuestConditionCounterCondition GenerateQuestConditionCounter(Exit exit)
{
return new QuestConditionCounterCondition
{
Id = hashUtil.Generate(),
DynamicLocale = true,
ExitName = exit.Name,
ConditionType = "ExitName",
};
}
}
@@ -0,0 +1,16 @@
using SPTarkov.Server.Core.Models.Eft.Common.Tables;
using SPTarkov.Server.Core.Models.Spt.Config;
using SPTarkov.Server.Core.Models.Spt.Repeatable;
namespace SPTarkov.Server.Core.Generators.RepeatableQuestGeneration;
public interface IRepeatableQuestGenerator
{
public RepeatableQuest? Generate(
string sessionId,
int pmcLevel,
string traderId,
QuestTypePool questTypePool,
RepeatableQuestConfig repeatableConfig
);
}
@@ -0,0 +1,87 @@
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.Spt.Repeatable;
using SPTarkov.Server.Core.Models.Utils;
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 PickupQuestGenerator(
ISptLogger<PickupQuestGenerator> logger,
RepeatableQuestHelper repeatableQuestHelper,
RepeatableQuestRewardGenerator repeatableQuestRewardGenerator,
DatabaseService databaseService,
LocalisationService localisationService,
RandomUtil randomUtil,
MathUtil mathUtil,
HashUtil hashUtil
) : IRepeatableQuestGenerator
{
// TODO: This isn't really implemented well at all, what even is this.
public RepeatableQuest? Generate(
string sessionId,
int pmcLevel,
string traderId,
QuestTypePool questTypePool,
RepeatableQuestConfig repeatableConfig)
{
var pickupConfig = repeatableConfig.QuestConfig.Pickup;
var quest = repeatableQuestHelper.GenerateRepeatableTemplate(
RepeatableQuestType.Pickup,
traderId,
repeatableConfig.Side,
sessionId
);
var itemTypeToFetchWithCount = randomUtil.GetArrayValue(
pickupConfig.ItemTypeToFetchWithMaxCount
);
var itemCountToFetch = randomUtil.RandInt(
itemTypeToFetchWithCount.MinimumPickupCount.Value,
itemTypeToFetchWithCount.MaximumPickupCount + 1
);
// Choose location - doesn't seem to work for anything other than 'any'
// var locationKey: string = this.randomUtil.drawRandomFromDict(questTypePool.pool.Pickup.locations)[0];
// var locationTarget = questTypePool.pool.Pickup.locations[locationKey];
var findCondition = quest.Conditions.AvailableForFinish.FirstOrDefault(x =>
x.ConditionType == "FindItem"
);
findCondition.Target = new ListOrT<string>([itemTypeToFetchWithCount.ItemType], null);
findCondition.Value = itemCountToFetch;
var counterCreatorCondition = quest.Conditions.AvailableForFinish.FirstOrDefault(x =>
x.ConditionType == "CounterCreator"
);
// var locationCondition = counterCreatorCondition._props.counter.conditions.find(x => x._parent === "Location");
// (locationCondition._props as ILocationConditionProps).target = [...locationTarget];
var equipmentCondition = counterCreatorCondition.Counter.Conditions.FirstOrDefault(x =>
x.ConditionType == "Equipment"
);
equipmentCondition.EquipmentInclusive =
[
[itemTypeToFetchWithCount.ItemType],
];
// Add rewards
quest.Rewards = repeatableQuestRewardGenerator.GenerateReward(
pmcLevel,
1,
traderId,
repeatableConfig,
pickupConfig
);
return quest;
}
}
@@ -19,7 +19,7 @@ public class RepeatableQuestHelper(
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
@@ -45,7 +45,7 @@ public class RepeatableQuestHelper(
/// <exception cref="ArgumentOutOfRangeException"></exception>
public Dictionary<string, string> GetRepeatableQuestTemplatesByGroup(PlayerGroup playerGroup)
{
var templates = _questConfig.RepeatableQuestTemplates;
var templates = QuestConfig.RepeatableQuestTemplates;
return playerGroup switch
{
@@ -196,4 +196,21 @@ public class RepeatableQuestHelper(
return questData;
}
/// <summary>
/// Convert a raw location string into a location code can read (e.g. factory4_day into 55f2d3fd4bdc2d5f408b4567)
/// </summary>
/// <param name="locationKey">e.g. factory4_day</param>
/// <returns>guid</returns>
public string? GetQuestLocationByMapId(string locationKey)
{
if (!QuestConfig.LocationIdMap.TryGetValue(locationKey, out var locationId))
{
// TODO - localize me!
logger.Error($"No location in LocationIdMap found for key {locationKey}");
return null;
}
return locationId;
}
}
@@ -406,8 +406,8 @@ public record SpecificExits
/// <summary>
/// Chance that an operational task is generated with a specific extract
/// </summary>
[JsonPropertyName("probability")]
public required double Probability { get; set; }
[JsonPropertyName("chance")]
public required double Chance { get; set; }
/// <summary>
/// Whitelist of specific extract types
@@ -9,10 +9,10 @@ public record QuestTypePool
public Dictionary<string, object> ExtensionData { get; set; }
[JsonPropertyName("types")]
public List<string>? Types { get; set; }
public required List<string> Types { get; set; }
[JsonPropertyName("pool")]
public QuestPool? Pool { get; set; }
public required QuestPool Pool { get; set; }
}
public record QuestPool
@@ -21,13 +21,13 @@ public record QuestPool
public Dictionary<string, object> ExtensionData { get; set; }
[JsonPropertyName("Exploration")]
public ExplorationPool? Exploration { get; set; }
public required ExplorationPool Exploration { get; set; }
[JsonPropertyName("Elimination")]
public EliminationPool? Elimination { get; set; }
public required EliminationPool Elimination { get; set; }
[JsonPropertyName("Pickup")]
public ExplorationPool? Pickup { get; set; }
public required ExplorationPool Pickup { get; set; }
}
public record ExplorationPool