This commit is contained in:
CWX
2025-01-23 13:38:07 +00:00
9 changed files with 627 additions and 296 deletions
@@ -1,19 +1,19 @@
using SptCommon.Annotations;
using Core.Generators;
using Core.Helpers;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
using Core.Models.Eft.ItemEvent;
using Core.Models.Eft.Quests;
using Core.Models.Enums;
using Core.Models.Spt.Config;
using Core.Models.Spt.Repeatable;
using Core.Generators;
using Core.Helpers;
using Core.Models.Enums;
using Core.Models.Utils;
using Core.Routers;
using Core.Servers;
using Core.Services;
using Core.Utils;
using Core.Utils.Cloners;
using SptCommon.Annotations;
namespace Core.Controllers;
@@ -39,7 +39,8 @@ public class RepeatableQuestController(
{
protected QuestConfig _questConfig = _configServer.GetConfig<QuestConfig>();
public ItemEventRouterResponse ChangeRepeatableQuest(PmcData pmcData, RepeatableQuestChangeRequest info, string sessionId)
public ItemEventRouterResponse ChangeRepeatableQuest(PmcData pmcData, RepeatableQuestChangeRequest info,
string sessionId)
{
throw new NotImplementedException();
}
@@ -108,7 +109,9 @@ public class RepeatableQuestController(
lifeline++;
if (lifeline > 10)
{
_logger.Debug("We were stuck in repeatable quest generation. This should never happen. Please report");
_logger.Debug(
"We were stuck in repeatable quest generation. This should never happen. Please report"
);
break;
}
@@ -133,13 +136,11 @@ public class RepeatableQuestController(
// Create stupid redundant change requirements from quest data
foreach (var quest in generatedRepeatables.ActiveQuests)
{
generatedRepeatables.ChangeRequirement[quest.Id] = new ChangeRequirement
{
ChangeCost = quest.ChangeCost,
ChangeStandingCost = _randomUtil.GetArrayValue([0, 0.01]), // Randomise standing cost to replace
ChangeStandingCost = _randomUtil.GetArrayValue([0, 0.01]) // Randomise standing cost to replace
};
}
// Reset free repeatable values in player profile to defaults
generatedRepeatables.FreeChanges = repeatableConfig.FreeChanges;
@@ -155,7 +156,7 @@ public class RepeatableQuestController(
InactiveQuests = generatedRepeatables.InactiveQuests,
ChangeRequirement = generatedRepeatables.ChangeRequirement,
FreeChanges = generatedRepeatables.FreeChanges,
FreeChangesAvailable = generatedRepeatables.FreeChanges,
FreeChangesAvailable = generatedRepeatables.FreeChanges
}
);
}
@@ -163,26 +164,26 @@ public class RepeatableQuestController(
return returnData;
}
private PmcDataRepeatableQuest GetRepeatableQuestSubTypeFromProfile(RepeatableQuestConfig repeatableConfig, PmcData pmcData)
private PmcDataRepeatableQuest GetRepeatableQuestSubTypeFromProfile(RepeatableQuestConfig repeatableConfig,
PmcData pmcData)
{
// Get from profile, add if missing
var repeatableQuestDetails = pmcData.RepeatableQuests.FirstOrDefault(
(repeatable) => repeatable.Name == repeatableConfig.Name
repeatable => repeatable.Name == repeatableConfig.Name
);
if (repeatableQuestDetails is null)
{
// Not in profile, generate
var hasAccess = _profileHelper.HasAccessToRepeatableFreeRefreshSystem(pmcData);
repeatableQuestDetails = new PmcDataRepeatableQuest()
repeatableQuestDetails = new PmcDataRepeatableQuest
{
Id = repeatableConfig.Id,
Name = repeatableConfig.Name,
ActiveQuests = [],
InactiveQuests = [],
EndTime = 0,
ChangeRequirement = { },
FreeChanges = hasAccess ? repeatableConfig.FreeChanges : 0,
FreeChangesAvailable = hasAccess ? repeatableConfig.FreeChangesAvailable : 0,
FreeChangesAvailable = hasAccess ? repeatableConfig.FreeChangesAvailable : 0
};
// Add base object that holds repeatable data to profile
@@ -229,9 +230,9 @@ public class RepeatableQuestController(
*/
private bool PlayerHasDailyScavQuestsUnlocked(PmcData pmcData)
{
return (
pmcData?.Hideout?.Areas?.FirstOrDefault((hideoutArea) => hideoutArea.Type == HideoutAreas.INTEL_CENTER)?.Level >= 1
);
return pmcData?.Hideout?.Areas?.FirstOrDefault(hideoutArea => hideoutArea.Type == HideoutAreas.INTEL_CENTER)
?.Level >=
1;
}
private void ProcessExpiredQuests(PmcDataRepeatableQuest generatedRepeatables, PmcData pmcData)
@@ -239,7 +240,7 @@ public class RepeatableQuestController(
var questsToKeep = new List<RepeatableQuest>();
foreach (var activeQuest in generatedRepeatables.ActiveQuests)
{
var questStatusInProfile = pmcData.Quests.FirstOrDefault((quest) => quest.QId == activeQuest.Id);
var questStatusInProfile = pmcData.Quests.FirstOrDefault(quest => quest.QId == activeQuest.Id);
if (questStatusInProfile is null)
{
continue;
@@ -249,7 +250,9 @@ public class RepeatableQuestController(
if (questStatusInProfile.Status == QuestStatusEnum.AvailableForFinish)
{
questsToKeep.Add(activeQuest);
_logger.Debug($"Keeping repeatable quest: {activeQuest.Id} in activeQuests since it is available to hand in");
_logger.Debug(
$"Keeping repeatable quest: {activeQuest.Id} in activeQuests since it is available to hand in"
);
continue;
}
@@ -258,7 +261,7 @@ public class RepeatableQuestController(
_profileFixerService.RemoveDanglingConditionCounters(pmcData);
// Remove expired quest from pmc.quest array
pmcData.Quests = pmcData.Quests.Where((quest) => quest.QId != activeQuest.Id).ToList();
pmcData.Quests = pmcData.Quests.Where(quest => quest.QId != activeQuest.Id).ToList();
// Store in inactive array
generatedRepeatables.InactiveQuests.Add(activeQuest);
@@ -276,27 +279,25 @@ public class RepeatableQuestController(
// Populate Exploration and Pickup quest locations
foreach (var (location, value) in locations)
{
if (location != ELocationName.any)
{
questPool.Pool.Exploration.Locations[location] = value;
questPool.Pool.Pickup.Locations[location] = value;
}
}
// Add "any" to pickup quest pool
questPool.Pool.Pickup.Locations[ELocationName.any] = ["any"];
var eliminationConfig = _repeatableQuestHelper.GetEliminationConfigByPmcLevel(pmcLevel.Value, repeatableConfig);
var targetsConfig = _repeatableQuestHelper.ProbabilityObjectArray<Target, string, BossInfo>(eliminationConfig.Targets);
var targetsConfig =
_repeatableQuestHelper.ProbabilityObjectArray<Target, string, BossInfo>(eliminationConfig.Targets);
// Populate Elimination quest targets and their locations
foreach (var targetKvP in targetsConfig)
{
// Target is boss
if (targetKvP.Data.IsBoss.GetValueOrDefault(false))
{
questPool.Pool.Elimination.Targets[targetKvP.Key] = new TargetLocation{ Locations = ["any"] };
questPool.Pool.Elimination.Targets[targetKvP.Key] = new TargetLocation { Locations = ["any"] };
}
else
{
@@ -305,12 +306,14 @@ public class RepeatableQuestController(
var allowedLocations =
targetKvP.Key == "Savage"
? possibleLocations.Where((location) => location != ELocationName.laboratory) // Exclude labs for Savage targets.
: possibleLocations;
? possibleLocations.Where(
location => location != ELocationName.laboratory
) // Exclude labs for Savage targets.
: possibleLocations;
questPool.Pool.Elimination.Targets[targetKvP.Key] = new TargetLocation{ Locations = allowedLocations.Select(x => x.ToString()).ToList() };
questPool.Pool.Elimination.Targets[targetKvP.Key] = new TargetLocation
{ Locations = allowedLocations.Select(x => x.ToString()).ToList() };
}
}
return questPool;
}
@@ -334,11 +337,12 @@ public class RepeatableQuestController(
{
Locations = new Dictionary<ELocationName, List<string>>()
}
},
}
};
}
private Dictionary<ELocationName, List<string>> GetAllowedLocationsForPmcLevel(Dictionary<ELocationName, List<string>> locations, int pmcLevel)
private Dictionary<ELocationName, List<string>> GetAllowedLocationsForPmcLevel(
Dictionary<ELocationName, List<string>> locations, int pmcLevel)
{
var allowedLocation = new Dictionary<ELocationName, List<string>>();
@@ -346,12 +350,10 @@ public class RepeatableQuestController(
{
var locationNames = new List<string>();
foreach (var locationName in value)
{
if (IsPmcLevelAllowedOnLocation(locationName, pmcLevel))
{
locationNames.Add(locationName);
}
}
if (locationNames.Count > 0)
{
@@ -363,11 +365,11 @@ public class RepeatableQuestController(
}
/**
* Return true if the given pmcLevel is allowed on the given location
* @param location The location name to check
* @param pmcLevel The level of the pmc
* @returns True if the given pmc level is allowed to access the given location
*/
* Return true if the given pmcLevel is allowed on the given location
* @param location The location name to check
* @param pmcLevel The level of the pmc
* @returns True if the given pmc level is allowed to access the given location
*/
protected bool IsPmcLevelAllowedOnLocation(string location, int pmcLevel)
{
// All PMC levels are allowed for 'any' location requirement
@@ -386,7 +388,7 @@ public class RepeatableQuestController(
}
/// <summary>
/// Get count of repeatable quests profile should have access to
/// Get count of repeatable quests profile should have access to
/// </summary>
/// <param name="repeatableConfig"></param>
/// <param name="pmcData">Player profile</param>
@@ -400,7 +402,8 @@ public class RepeatableQuestController(
}
// Add elite bonus to daily quests
if (repeatableConfig.Name.ToLower() == "daily" && _profileHelper.HasEliteSkillLevel(SkillTypes.Charisma, pmcData)
if (repeatableConfig.Name.ToLower() == "daily" &&
_profileHelper.HasEliteSkillLevel(SkillTypes.Charisma, pmcData)
)
{
// Elite charisma skill gives extra daily quest(s)
@@ -6,11 +6,11 @@ using Core.Models.Spt.Bots;
using Core.Models.Spt.Config;
using Core.Models.Utils;
using Core.Helpers;
using Core.Models.Spt.Server;
using Core.Servers;
using Core.Services;
using Core.Utils;
using Core.Utils.Cloners;
using Core.Utils.Collections;
namespace Core.Generators;
@@ -1,18 +1,19 @@
using SptCommon.Annotations;
using Core.Helpers;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
using Core.Models.Enums;
using Core.Models.Spt.Config;
using Core.Models.Spt.Repeatable;
using Core.Models.Utils;
using Core.Utils;
using Core.Helpers;
using Core.Servers;
using Core.Services;
using Core.Utils;
using Core.Utils.Cloners;
using Core.Utils.Collections;
using Core.Utils.Json;
using SptCommon.Annotations;
using SptCommon.Extensions;
using BodyPart = Core.Models.Spt.Config.BodyPart;
using Core.Utils.Cloners;
namespace Core.Generators;
@@ -35,8 +36,10 @@ public class RepeatableQuestGenerator(
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).
/// It randomly draws a quest type (currently Elimination, Completion or Exploration) as well as a trader who is providing the quest
/// 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>
@@ -74,13 +77,16 @@ public class RepeatableQuestGenerator(
}
/// <summary>
/// Generate a randomised Elimination quest
/// Generate a randomised Elimination quest
/// </summary>
/// <param name="sessionId">Session id</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="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</param>
/// <param name="repeatableConfig">
/// The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig
/// for the requestd quest
/// </param>
/// <returns>Object of quest type format for "Elimination" (see assets/database/templates/repeatableQuests.json)</returns>
protected RepeatableQuest GenerateEliminationQuest(
string sessionId,
@@ -94,12 +100,18 @@ public class RepeatableQuestGenerator(
var eliminationConfig = _repeatableQuestHelper.GetEliminationConfigByPmcLevel(pmcLevel, repeatableConfig);
var locationsConfig = repeatableConfig.Locations;
var targetsConfig = _repeatableQuestHelper.ProbabilityObjectArray<Target, string, BossInfo>(eliminationConfig.Targets);
var bodyPartsConfig = _repeatableQuestHelper.ProbabilityObjectArray<BodyPart, string, List<string>>(eliminationConfig.BodyParts);
var targetsConfig =
_repeatableQuestHelper.ProbabilityObjectArray<Target, string, BossInfo>(eliminationConfig.Targets);
var bodyPartsConfig =
_repeatableQuestHelper.ProbabilityObjectArray<BodyPart, string, List<string>>(eliminationConfig.BodyParts);
var weaponCategoryRequirementConfig =
_repeatableQuestHelper.ProbabilityObjectArray<WeaponRequirement, string, List<string>>(eliminationConfig.WeaponCategoryRequirements);
_repeatableQuestHelper.ProbabilityObjectArray<WeaponRequirement, string, List<string>>(
eliminationConfig.WeaponCategoryRequirements
);
var weaponRequirementConfig =
_repeatableQuestHelper.ProbabilityObjectArray<WeaponRequirement, string, List<string>>(eliminationConfig.WeaponRequirements);
_repeatableQuestHelper.ProbabilityObjectArray<WeaponRequirement, string, List<string>>(
eliminationConfig.WeaponRequirements
);
// the difficulty of the quest varies in difficulty depending on the condition
// possible conditions are
@@ -122,7 +134,8 @@ public class RepeatableQuestGenerator(
// times the number of kills we have to perform):
// the minimum difficulty is the difficulty for the most probable (= easiest target) with no additional conditions
var minDifficulty = 1 / targetsConfig.MaxProbability(); // min difficulty is lowest amount of scavs without any constraints
var minDifficulty =
1 / targetsConfig.MaxProbability(); // min difficulty is lowest amount of scavs without any constraints
// Target on bodyPart max. difficulty is that of the least probable element
var maxTargetDifficulty = 1 / targetsConfig.MinProbability();
@@ -134,14 +147,14 @@ public class RepeatableQuestGenerator(
var maxKillDifficulty = eliminationConfig.MaxKills;
var targetPool = questTypePool.Pool.Elimination;
targetsConfig = targetsConfig.Filter((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)))
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
// also if only bosses are left we need to leave otherwise it's a guaranteed boss elimination
// -> then it would not be a quest with low probability anymore
questTypePool.Types = questTypePool.Types.Where((t) => t != "Elimination").ToList();
questTypePool.Types = questTypePool.Types.Where(t => t != "Elimination").ToList();
return null;
}
@@ -154,7 +167,8 @@ public class RepeatableQuestGenerator(
// we use any as location if "any" is in the pool and we do not hit the specific location random
// we use any also if the random condition is not met in case only "any" was in the pool
var locationKey = "any";
if (locations.Contains("any") && (eliminationConfig.SpecificLocationProbability < rand.Next() || locations.Count <= 1)
if (locations.Contains("any") &&
(eliminationConfig.SpecificLocationProbability < rand.Next() || locations.Count <= 1)
)
{
locationKey = "any";
@@ -166,11 +180,13 @@ public class RepeatableQuestGenerator(
if (locations.Count > 0)
{
locationKey = _randomUtil.DrawRandomFromList(locations).FirstOrDefault();
questTypePool.Pool.Elimination.Targets.GetByJsonProp<TargetLocation>(targetKey).Locations = locations.Where(
(l) => l != locationKey
questTypePool.Pool.Elimination.Targets.GetByJsonProp<TargetLocation>(targetKey).Locations = locations
.Where(
l => l != locationKey
)
.ToList();
if (questTypePool.Pool.Elimination.Targets.GetByJsonProp<TargetLocation>(targetKey).Locations.Count == 0)
if (questTypePool.Pool.Elimination.Targets.GetByJsonProp<TargetLocation>(targetKey).Locations.Count ==
0)
{
questTypePool.Pool.Elimination.Targets.Remove(targetKey);
}
@@ -197,10 +213,7 @@ public class RepeatableQuestGenerator(
{
// more than one part lead to an "OR" condition hence more parts reduce the difficulty
probability += bodyPartsConfig.Probability(bi).Value;
foreach (var biClient in bodyPartsConfig.Data(bi))
{
bodyPartsToClient.Add(biClient);
}
foreach (var biClient in bodyPartsConfig.Data(bi)) bodyPartsToClient.Add(biClient);
}
bodyPartDifficulty = 1 / probability;
@@ -218,7 +231,7 @@ public class RepeatableQuestGenerator(
.GetDictionary()
.Select(x => x.Value)
.Where(x => x.Base?.Id != null)
.Select(x => (new { x.Base.Id, BossSpawn = x.Base.BossLocationSpawn }));
.Select(x => new { x.Base.Id, BossSpawn = x.Base.BossLocationSpawn });
// filter for the current boss to spawn on map
var thisBossSpawns = bossSpawns
.Select(
@@ -229,9 +242,9 @@ public class RepeatableQuestGenerator(
.Where(e => e.BossName == targetKey)
}
)
.Where((x) => x.BossSpawn.Count() > 0);
.Where(x => x.BossSpawn.Count() > 0);
// remove blacklisted locations
var allowedSpawns = thisBossSpawns.Where((x) => !eliminationConfig.DistLocationBlacklist.Contains(x.Id));
var allowedSpawns = thisBossSpawns.Where(x => !eliminationConfig.DistLocationBlacklist.Contains(x.Id));
// if the boss spawns on nom-blacklisted locations and the current location is allowed we can generate a distance kill requirement
isDistanceRequirementAllowed = isDistanceRequirementAllowed && allowedSpawns.Count() > 0;
}
@@ -240,7 +253,8 @@ public class RepeatableQuestGenerator(
{
// Random distance with lower values more likely; simple distribution for starters...
distance = (int)Math.Floor(
(decimal)(Math.Abs(rand.Next(0, 1) - rand.Next(0, 1)) * (1 + eliminationConfig.MaxDistance - eliminationConfig.MinDistance) +
(decimal)(Math.Abs(rand.Next(0, 1) - rand.Next(0, 1)) *
(1 + eliminationConfig.MaxDistance - eliminationConfig.MinDistance) +
eliminationConfig.MinDistance)
);
distance = (int)Math.Ceiling((decimal)(distance / 5)) * 5;
@@ -254,21 +268,23 @@ public class RepeatableQuestGenerator(
if (distance > 50)
{
List<string> weaponTypes = ["Shotgun", "Pistol"];
weaponCategoryRequirementConfig = (ProbabilityObjectArray<WeaponRequirement, string, List<string>>)weaponCategoryRequirementConfig
.Where(
(category) => weaponTypes
.Contains(category.Key)
);
weaponCategoryRequirementConfig =
(ProbabilityObjectArray<WeaponRequirement, string, List<string>>)weaponCategoryRequirementConfig
.Where(
category => weaponTypes
.Contains(category.Key)
);
}
else if (distance < 20)
{
List<string> weaponTypes = ["MarksmanRifle", "DMR"];
// Filter out far range weapons from close distance requirement
weaponCategoryRequirementConfig = (ProbabilityObjectArray<WeaponRequirement, string, List<string>>)weaponCategoryRequirementConfig
.Where(
(category) => weaponTypes
.Contains(category.Key)
);
weaponCategoryRequirementConfig =
(ProbabilityObjectArray<WeaponRequirement, string, List<string>>)weaponCategoryRequirementConfig
.Where(
category => weaponTypes
.Contains(category.Key)
);
}
// Pick a weighted weapon category
@@ -324,7 +340,9 @@ public class RepeatableQuestGenerator(
if (locationKey != "any")
{
Enum.TryParse(typeof(ELocationName), locationKey, true, out var locationId);
availableForFinishCondition.Counter.Conditions.Add(GenerateEliminationLocation(locationsConfig[(ELocationName)locationId]));
availableForFinishCondition.Counter.Conditions.Add(
GenerateEliminationLocation(locationsConfig[(ELocationName)locationId])
);
}
availableForFinishCondition.Counter.Conditions.Add(
@@ -387,8 +405,9 @@ public class RepeatableQuestGenerator(
}
/// <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 GenerateEliminationQuest to create a location condition.
/// 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 GenerateEliminationQuest to create a location condition.
/// </summary>
/// <param name="location">the location on which to fulfill the elimination quest</param>
/// <returns>Elimination-location-subcondition object</returns>
@@ -404,7 +423,7 @@ public class RepeatableQuestGenerator(
}
/// <summary>
/// Create kill condition for an elimination quest
/// Create kill condition for an elimination quest
/// </summary>
/// <param name="target">Bot type target of elimination quest e.g. "AnyPmc", "Savage"</param>
/// <param name="targetedBodyParts">Body parts player must hit</param>
@@ -428,7 +447,7 @@ public class RepeatableQuestGenerator(
Value = 1,
ResetOnSessionEnd = false,
EnemyHealthEffects = [],
Daytime = new DaytimeCounter() { From = 0, To = 0 },
Daytime = new DaytimeCounter { From = 0, To = 0 },
ConditionType = "Kills"
};
@@ -467,11 +486,14 @@ public class RepeatableQuestGenerator(
}
/// <summary>
/// Generates a valid Completion quest
/// 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>
/// <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,
@@ -489,55 +511,72 @@ public class RepeatableQuestGenerator(
// 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);
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));
(double)(_mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) * multi)
);
roublesBudget = Math.Max(roublesBudget, 5000d);
var itemSelection = possibleItemsToRetrievePool.Where(
(x) => _itemHelper.GetItemPrice(x.Id) < roublesBudget).ToList();
x => _itemHelper.GetItemPrice(x.Id) < 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)) {
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.Id, v)) ||
itemIdsWhitelisted.Contains(x.Id)
);
}).ToList();
.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.Id, v)) ||
itemIdsWhitelisted.Contains(x.Id);
}
)
.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)) {
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) );
.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.Id, v)) ||
!itemIdsBlacklisted.Contains(x.Id)
);
}).ToList();
itemSelection = itemSelection.Where(
x =>
{
return itemIdsBlacklisted.All(v => !_itemHelper.IsOfBaseclass(x.Id, v)) ||
!itemIdsBlacklisted.Contains(x.Id);
}
)
.ToList();
}
if (!itemSelection.Any()) {
_logger.Error(_localisationService.GetText("repeatable-completion_quest_whitelist_too_small_or_blacklist_too_restrictive"));
if (!itemSelection.Any())
{
_logger.Error(
_localisationService.GetText(
"repeatable-completion_quest_whitelist_too_small_or_blacklist_too_restrictive"
)
);
return null;
}
@@ -549,67 +588,89 @@ public class RepeatableQuestGenerator(
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++) {
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)) {
for (var j = 0; j < _maxRandomNumberAttempts; j++)
if (usedItemIndexes.Contains(chosenItemIndex))
{
chosenItemIndex = _randomUtil.RandInt(itemSelection.Count());
} else {
}
else
{
found = true;
break;
}
}
if (!found) {
_logger.Error(_localisationService.GetText("repeatable-no_reward_item_found_in_price_range", new {
minPrice = 0,
roublesBudget = roublesBudget }));
if (!found)
{
_logger.Error(
_localisationService.GetText(
"repeatable-no_reward_item_found_in_price_range",
new
{
minPrice = 0, roublesBudget
}
)
);
return null;
}
usedItemIndexes.Add(chosenItemIndex);
var itemSelected = itemSelection[chosenItemIndex];
var itemUnitPrice = _itemHelper.GetItemPrice(itemSelected.Id).Value;
var minValue = (double)completionConfig.MinimumRequestedAmount.Value;
var maxValue = (double)completionConfig.MaximumRequestedAmount.Value;
if (_itemHelper.IsOfBaseclass(itemSelected.Id, BaseClasses.AMMO)) {
if (_itemHelper.IsOfBaseclass(itemSelected.Id, BaseClasses.AMMO))
{
// Prevent multiple ammo requirements from being picked
if (isAmmo > 0 && isAmmo < _maxRandomNumberAttempts) {
if (isAmmo > 0 && isAmmo < _maxRandomNumberAttempts)
{
isAmmo++;
i--;
continue;
}
isAmmo++;
minValue = (double)completionConfig.MinimumRequestedBulletAmount.Value;
maxValue = (double)completionConfig.MaximumRequestedBulletAmount.Value;
minValue = completionConfig.MinimumRequestedBulletAmount.Value;
maxValue = 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 (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.Id);
quest.Conditions.AvailableForFinish.Add(GenerateCompletionAvailableForFinish(itemSelected.Id, value));
if (roublesBudget > 0) {
if (roublesBudget > 0)
{
// Reduce the list possible items to fulfill the new budget constraint
itemSelection = itemSelection.Where((dbItem) => _itemHelper.GetItemPrice(dbItem.Id) < roublesBudget).ToList();
if (!itemSelection.Any()) {
itemSelection = itemSelection.Where(dbItem => _itemHelper.GetItemPrice(dbItem.Id) < roublesBudget)
.ToList();
if (!itemSelection.Any())
{
break;
}
} else {
}
else
{
break;
}
}
@@ -620,32 +681,69 @@ public class RepeatableQuestGenerator(
traderId,
repeatableConfig,
completionConfig,
chosenRequirementItemsTpls);
chosenRequirementItemsTpls
);
return quest;
}
/// <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)
/// 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>
/// <returns>object of "Completion"-condition</returns>
protected QuestCondition GenerateCompletionAvailableForFinish(string itemTpl, double value)
{
_logger.Error("NOT IMPLEMENTED - GenerateCompletionAvailableForFinish");
throw new NotImplementedException();
var minDurability = 0;
var onlyFoundInRaid = true;
if (
_itemHelper.IsOfBaseclass(itemTpl, BaseClasses.WEAPON) ||
_itemHelper.IsOfBaseclass(itemTpl, BaseClasses.ARMOR)
)
{
minDurability = _randomUtil.GetArrayValue([60, 80]);
}
// By default all collected items must be FiR, except dog tags
if (_itemHelper.IsDogtag(itemTpl))
{
onlyFoundInRaid = false;
}
return new QuestCondition
{
Id = _hashUtil.Generate(),
Index = 0,
ParentId = "",
DynamicLocale = true,
VisibilityConditions = [],
GlobalQuestCounterId = "",
Target = new ListOrT<string>([], itemTpl),
Value = value,
MinDurability = minDurability,
MaxDurability = 100,
DogtagLevel = 0,
OnlyFoundInRaid = onlyFoundInRaid,
IsEncoded = false,
ConditionType = "HandoverItem"
};
}
/// <summary>
/// Generates a valid Exploration quest
/// 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>
/// <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,
@@ -661,7 +759,7 @@ public class RepeatableQuestGenerator(
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();
questTypePool.Types = questTypePool.Types.Where(t => t != "Exploration").ToList();
return null;
}
@@ -681,17 +779,19 @@ public class RepeatableQuestGenerator(
var quest = GenerateRepeatableTemplate("Exploration", traderId, repeatableConfig.Side, sessionId);
var exitStatusCondition = new QuestConditionCounterCondition{
var exitStatusCondition = new QuestConditionCounterCondition
{
Id = _hashUtil.Generate(),
DynamicLocale = true,
Status = ["Survived"],
ConditionType = "ExitStatus",
ConditionType = "ExitStatus"
};
var locationCondition = new QuestConditionCounterCondition{
var locationCondition = new QuestConditionCounterCondition
{
Id = _hashUtil.Generate(),
DynamicLocale = true,
Target = locationTarget,
ConditionType = "Location",
ConditionType = "Location"
};
quest.Conditions.AvailableForFinish[0].Counter.Id = _hashUtil.Generate();
@@ -706,13 +806,17 @@ public class RepeatableQuestGenerator(
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();
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();
exit =>
exit.PassageRequirement is not null ||
repeatableConfig.QuestConfig.Exploration.SpecificExits.PassageRequirementWhitelist.Contains(
"PassageRequirement"
)
)
.ToList();
if (possibleExits.Count == 0)
{
@@ -721,7 +825,7 @@ public class RepeatableQuestGenerator(
else
{
// Choose one of the exits we filtered above
var chosenExit = _randomUtil.DrawRandomFromList(possibleExits, 1)[0];
var chosenExit = _randomUtil.DrawRandomFromList(possibleExits)[0];
// Create a quest condition to leave raid via chosen exit
var exitCondition = GenerateExplorationExitCondition(chosenExit);
@@ -737,13 +841,14 @@ public class RepeatableQuestGenerator(
difficulty,
traderId,
repeatableConfig,
explorationConfig);
explorationConfig
);
return quest;
}
/// <summary>
/// Filter a maps exits to just those for the desired side
/// Filter a maps exits to just those for the desired side
/// </summary>
/// <param name="locationKey">Map id (e.g. factory4_day)</param>
/// <param name="playerSide">Scav/Pmc</param>
@@ -752,7 +857,7 @@ public class RepeatableQuestGenerator(
{
var mapExtracts = _databaseService.GetLocation(locationKey.ToLower()).AllExtracts;
return mapExtracts.Where((exit) => exit.Side == playerSide).ToList();
return mapExtracts.Where(exit => exit.Side == playerSide).ToList();
}
protected RepeatableQuest GeneratePickupQuest(
@@ -762,11 +867,48 @@ public class RepeatableQuestGenerator(
QuestTypePool questTypePool,
RepeatableQuestConfig repeatableConfig)
{
throw new NotImplementedException();
var pickupConfig = repeatableConfig.QuestConfig.Pickup;
var quest = GenerateRepeatableTemplate("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);
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)
/// 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>
@@ -776,14 +918,15 @@ public class RepeatableQuestGenerator(
}
/// <summary>
/// Exploration repeatable quests can specify a required extraction point.
/// This method creates the according object which will be appended to the conditions list
/// 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 {
return new QuestConditionCounterCondition
{
Id = _hashUtil.Generate(),
DynamicLocale = true,
ExitName = exit.Name,
@@ -792,14 +935,17 @@ public class RepeatableQuestGenerator(
}
/// <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
/// 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="side">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>
/// <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,
@@ -822,6 +968,7 @@ public class RepeatableQuestGenerator(
questData = _databaseService.GetTemplates().RepeatableQuests.Templates.Pickup;
break;
}
var questClone = _cloner.Clone(questData);
questClone.Id = _hashUtil.Generate();
questClone.TraderId = traderId;
@@ -1,5 +1,5 @@
using SptCommon.Annotations;
using Core.Helpers;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
using Core.Models.Enums;
using Core.Models.Spt.Config;
@@ -9,8 +9,8 @@ using Core.Servers;
using Core.Services;
using Core.Utils;
using Core.Utils.Cloners;
using Core.Models.Eft.Common;
using Core.Models.Spt.Server;
using Core.Utils.Collections;
using SptCommon.Annotations;
namespace Core.Generators;
@@ -42,12 +42,12 @@ public class RepeatableQuestRewardGenerator(
* - Items
* - Trader Reputation
* - Skill level experience
*
*
* The reward is dependent on the player level as given by the wiki. The exact mapping of pmcLevel to
* experience / money / items / trader reputation can be defined in QuestConfig.js
*
*
* There's also a random variation of the reward the spread of which can be also defined in the config
*
*
* Additionally, a scaling factor w.r.t. quest difficulty going from 0.2...1 can be used
* @param pmcLevel Level of player reward is being generated for
* @param difficulty Reward scaling factor from 0.2 to 1
@@ -81,7 +81,7 @@ public class RepeatableQuestRewardGenerator(
if (rewardParams.RewardXP > 0)
{
rewards.Success.Add(
new()
new Reward
{
Id = _hashUtil.Generate(),
Unknown = false,
@@ -105,9 +105,10 @@ public class RepeatableQuestRewardGenerator(
// Add preset weapon to reward if checks pass
var traderWhitelistDetails = repeatableConfig.TraderWhitelist.FirstOrDefault(
(traderWhitelist) => traderWhitelist.TraderId == traderId
traderWhitelist => traderWhitelist.TraderId == traderId
);
if (traderWhitelistDetails?.RewardCanBeWeapon ?? false && _randomUtil.GetChance100(traderWhitelistDetails.WeaponRewardChancePercent ?? 0)
if (traderWhitelistDetails?.RewardCanBeWeapon ??
(false && _randomUtil.GetChance100(traderWhitelistDetails.WeaponRewardChancePercent ?? 0))
)
{
var chosenWeapon = GetRandomWeaponPresetWithinBudget(itemRewardBudget.Value, rewardIndex);
@@ -126,7 +127,7 @@ public class RepeatableQuestRewardGenerator(
{
// Filter reward pool of items from blacklist, only use if there's at least 1 item remaining
var filteredRewardItemPool = inBudgetRewardItemPool.Where(
(item) => !rewardTplBlacklist.Contains(item.Id)
item => !rewardTplBlacklist.Contains(item.Id)
);
if (filteredRewardItemPool.Count() > 0)
{
@@ -142,8 +143,8 @@ public class RepeatableQuestRewardGenerator(
{
var itemsToReward = GetRewardableItemsFromPoolWithinBudget(
inBudgetRewardItemPool,
rewardParams.RewardNumItems,
itemRewardBudget,
rewardParams.RewardNumItems.Value,
itemRewardBudget.Value,
repeatableConfig
);
@@ -217,40 +218,61 @@ public class RepeatableQuestRewardGenerator(
_logger.Warning(_localisationService.GetText("repeatable-difficulty_was_nan"));
}
return new()
return new QuestRewardValues
{
SkillPointReward = _mathUtil.Interp1(pmcLevel, levelsConfig, skillPointRewardConfig),
SkillRewardChance = _mathUtil.Interp1(pmcLevel, levelsConfig, skillRewardChanceConfig),
RewardReputation = GetRewardRep(effectiveDifficulty, pmcLevel, levelsConfig, reputationConfig, rewardSpreadConfig),
RewardReputation = GetRewardRep(
effectiveDifficulty,
pmcLevel,
levelsConfig,
reputationConfig,
rewardSpreadConfig
),
RewardNumItems = GetRewardNumItems(pmcLevel, levelsConfig, itemsConfig),
RewardRoubles = GetRewardRoubles(effectiveDifficulty, pmcLevel, levelsConfig, roublesConfig, rewardSpreadConfig),
GpCoinRewardCount = GetGpCoinRewardCount(effectiveDifficulty, pmcLevel, levelsConfig, gpCoinConfig, rewardSpreadConfig),
RewardXP = GetRewardXp(effectiveDifficulty, pmcLevel, levelsConfig, xpConfig, rewardSpreadConfig),
RewardRoubles = GetRewardRoubles(
effectiveDifficulty,
pmcLevel,
levelsConfig,
roublesConfig,
rewardSpreadConfig
),
GpCoinRewardCount = GetGpCoinRewardCount(
effectiveDifficulty,
pmcLevel,
levelsConfig,
gpCoinConfig,
rewardSpreadConfig
),
RewardXP = GetRewardXp(effectiveDifficulty, pmcLevel, levelsConfig, xpConfig, rewardSpreadConfig)
};
}
private double GetRewardXp(double? effectiveDifficulty, int pmcLevel, List<double>? levelsConfig, List<double>? xpConfig, double? rewardSpreadConfig)
private double GetRewardXp(double? effectiveDifficulty, int pmcLevel, List<double>? levelsConfig,
List<double>? xpConfig, double? rewardSpreadConfig)
{
return Math.Floor(
(effectiveDifficulty *
_mathUtil.Interp1(pmcLevel, levelsConfig, xpConfig) *
_randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig))) ??
effectiveDifficulty *
_mathUtil.Interp1(pmcLevel, levelsConfig, xpConfig) *
_randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig)) ??
0
);
}
private double GetGpCoinRewardCount(double? effectiveDifficulty, int pmcLevel, List<double>? levelsConfig, List<double>? gpCoinConfig,
private double GetGpCoinRewardCount(double? effectiveDifficulty, int pmcLevel, List<double>? levelsConfig,
List<double>? gpCoinConfig,
double? rewardSpreadConfig)
{
return Math.Ceiling(
(effectiveDifficulty *
_mathUtil.Interp1(pmcLevel, levelsConfig, gpCoinConfig) *
_randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig))) ??
effectiveDifficulty *
_mathUtil.Interp1(pmcLevel, levelsConfig, gpCoinConfig) *
_randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig)) ??
0
);
}
private double GetRewardRep(double? effectiveDifficulty, int pmcLevel, List<double>? levelsConfig, List<double>? reputationConfig,
private double GetRewardRep(double? effectiveDifficulty, int pmcLevel, List<double>? levelsConfig,
List<double>? reputationConfig,
double? rewardSpreadConfig)
{
return Math.Round(
@@ -263,29 +285,175 @@ public class RepeatableQuestRewardGenerator(
100;
}
private double GetRewardNumItems(int pmcLevel, List<double>? levelsConfig, List<double>? itemsConfig)
private int GetRewardNumItems(int pmcLevel, List<double>? levelsConfig, List<double>? itemsConfig)
{
return _randomUtil.RandInt(1, (int)Math.Round(_mathUtil.Interp1(pmcLevel, levelsConfig, itemsConfig) ?? 0) + 1);
}
private double GetRewardRoubles(double? effectiveDifficulty, int pmcLevel, List<double>? levelsConfig, List<double>? roublesConfig,
private double GetRewardRoubles(double? effectiveDifficulty, int pmcLevel, List<double>? levelsConfig,
List<double>? roublesConfig,
double? rewardSpreadConfig)
{
return Math.Floor(
(effectiveDifficulty *
_mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) *
_randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig))) ??
effectiveDifficulty *
_mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) *
_randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig)) ??
0
);
}
private List<KeyValuePair<TemplateItem, double>> GetRewardableItemsFromPoolWithinBudget(List<TemplateItem> inBudgetRewardItemPool,
object rewardNumItems, double? itemRewardBudget, RepeatableQuestConfig repeatableConfig)
private Dictionary<TemplateItem, int> GetRewardableItemsFromPoolWithinBudget(List<TemplateItem> itemPool,
int maxItemCount, double itemRewardBudget, RepeatableQuestConfig repeatableConfig)
{
throw new NotImplementedException();
var itemsToReturn = new Dictionary<TemplateItem, int>();
var exhausableItemPool = new ExhaustableArray<TemplateItem>(itemPool, _randomUtil, _cloner);
for (var i = 0; i < maxItemCount; i++)
{
// Default stack size to 1
var rewardItemStackCount = 1;
// Get a random item
var chosenItemFromPool = exhausableItemPool.GetRandomValue();
if (!exhausableItemPool.HasValues())
{
break;
}
// Handle edge case - ammo
if (_itemHelper.IsOfBaseclass(chosenItemFromPool.Id, BaseClasses.AMMO))
{
// Don't reward ammo that stacks to less than what's allowed in config
if (chosenItemFromPool.Properties.StackMaxSize < repeatableConfig.RewardAmmoStackMinSize)
{
i--;
continue;
}
// Choose smallest value between budget, fitting size and stack max
rewardItemStackCount = CalculateAmmoStackSizeThatFitsBudget(
chosenItemFromPool,
itemRewardBudget,
maxItemCount
);
}
// 25% chance to double, triple or quadruple reward stack
// (Only occurs when item is stackable and not weapon, armor or ammo)
if (CanIncreaseRewardItemStackSize(chosenItemFromPool, 70000, 25))
{
rewardItemStackCount = GetRandomisedRewardItemStackSizeByPrice(chosenItemFromPool);
}
itemsToReturn.Add(chosenItemFromPool, rewardItemStackCount);
var itemCost = _presetHelper.GetDefaultPresetOrItemPrice(chosenItemFromPool.Id);
var calculatedItemRewardBudget = itemRewardBudget - rewardItemStackCount * itemCost;
_logger.Debug($"Added item: {chosenItemFromPool.Id} with price: {rewardItemStackCount * itemCost}");
// If we still have budget narrow down possible items
if (calculatedItemRewardBudget > 0)
{
// Filter possible reward items to only items with a price below the remaining budget
exhausableItemPool = new ExhaustableArray<TemplateItem>(
FilterRewardPoolWithinBudget(itemPool, calculatedItemRewardBudget, 0),
_randomUtil,
_cloner
);
if (!exhausableItemPool.HasValues())
{
_logger.Debug($"Reward pool empty with: {calculatedItemRewardBudget} roubles of budget remaining");
}
}
// No budget for more items, end loop
break;
}
return itemsToReturn;
}
private List<TemplateItem> ChooseRewardItemsWithinBudget(RepeatableQuestConfig repeatableConfig, double? roublesBudget, string traderId)
/**
* Get a count of cartridges that fits the rouble budget amount provided
* e.g. how many M80s for 50,000 roubles
* @param itemSelected Cartridge
* @param roublesBudget Rouble budget
* @param rewardNumItems
* @returns Count that fits budget (min 1)
*/
private int CalculateAmmoStackSizeThatFitsBudget(TemplateItem itemSelected, double roublesBudget,
int rewardNumItems)
{
// Calculate budget per reward item
var stackRoubleBudget = roublesBudget / rewardNumItems;
var singleCartridgePrice = _handbookHelper.GetTemplatePrice(itemSelected.Id);
// Get a stack size of ammo that fits rouble budget
var stackSizeThatFitsBudget = Math.Round(stackRoubleBudget / singleCartridgePrice.Value);
// Get itemDbs max stack size for ammo - don't go above 100 (some mods mess around with stack sizes)
var stackMaxCount = Math.Min(itemSelected.Properties.StackMaxSize.Value, 100);
// Ensure stack size is at least 1 + is no larger than the max possible stack size
return (int)Math.Max(1, Math.Min(stackSizeThatFitsBudget, stackMaxCount));
}
private bool CanIncreaseRewardItemStackSize(TemplateItem item, int maxRoublePriceToStack,
int? randomChanceToPass = null)
{
var isEligibleForStackSizeIncrease =
_presetHelper.GetDefaultPresetOrItemPrice(item.Id) < maxRoublePriceToStack &&
!_itemHelper.IsOfBaseclasses(
item.Id,
[
BaseClasses.WEAPON,
BaseClasses.ARMORED_EQUIPMENT,
BaseClasses.AMMO
]
) &&
!_itemHelper.ItemRequiresSoftInserts(item.Id);
return isEligibleForStackSizeIncrease && _randomUtil.GetChance100(randomChanceToPass ?? 100);
}
/**
* Get a randomised number a reward items stack size should be based on its handbook price
* @param item Reward item to get stack size for
* @returns matching stack size for the passed in items price
*/
private int GetRandomisedRewardItemStackSizeByPrice(TemplateItem item)
{
var rewardItemPrice = _presetHelper.GetDefaultPresetOrItemPrice(item.Id);
// Define price tiers and corresponding stack size options
var priceTiers = new List<Tuple<int, List<int>?>>
{
new(3000, [2, 3, 4]),
new(10000, [2, 3]),
new(int.MaxValue, [2, 3, 4]) // Default for prices 10001+ RUB
};
// Find the appropriate price tier and return a random stack size from its options
var tier = priceTiers.FirstOrDefault(tier => rewardItemPrice < tier.Item1);
if (tier is null)
{
return 4; // Default to 2 if no tier matches
}
return _randomUtil.GetArrayValue(tier.Item2);
}
/**
* Select a number of items that have a collective value of the passed in parameter
* @param repeatableConfig Config
* @param roublesBudget Total value of items to return
* @param traderId Id of the trader who will give player reward
* @returns Array of reward items that fit budget
*/
private List<TemplateItem> ChooseRewardItemsWithinBudget(RepeatableQuestConfig repeatableConfig,
double? roublesBudget, string traderId)
{
// First filter for type and baseclass to avoid lookup in handbook for non-available items
var rewardableItemPool = GetRewardableItems(repeatableConfig, traderId);
@@ -294,29 +462,47 @@ public class RepeatableQuestRewardGenerator(
var rewardableItemPoolWithinBudget = FilterRewardPoolWithinBudget(
rewardableItemPool,
roublesBudget.Value,
minPrice);
minPrice
);
if (rewardableItemPoolWithinBudget.Count == 0)
{
_logger.Warning(_localisationService.GetText("repeatable-no_reward_item_found_in_price_range", new {
minPrice = minPrice,
roublesBudget = roublesBudget }));
_logger.Warning(
_localisationService.GetText(
"repeatable-no_reward_item_found_in_price_range",
new
{
minPrice, roublesBudget
}
)
);
// In case we don't find any items in the price range
rewardableItemPoolWithinBudget = rewardableItemPool
.Where((x) => _itemHelper.GetItemPrice(x.Id) < roublesBudget)
.Where(x => _itemHelper.GetItemPrice(x.Id) < roublesBudget)
.ToList();
}
return rewardableItemPoolWithinBudget;
}
private List<TemplateItem> FilterRewardPoolWithinBudget(List<TemplateItem> rewardItems, double roublesBudget, double minPrice)
/**
* @param rewardItems List of reward items to filter
* @param roublesBudget The budget remaining for rewards
* @param minPrice The minimum priced item to include
* @returns True if any items remain in `rewardItems`, false otherwise
*/
private List<TemplateItem> FilterRewardPoolWithinBudget(List<TemplateItem> rewardItems, double roublesBudget,
double minPrice)
{
return rewardItems.Where((item) => {
var itemPrice = _presetHelper.GetDefaultPresetOrItemPrice(item.Id);
return itemPrice < roublesBudget && itemPrice > minPrice;
}).ToList();
return rewardItems.Where(
item =>
{
var itemPrice = _presetHelper.GetDefaultPresetOrItemPrice(item.Id);
return itemPrice < roublesBudget && itemPrice > minPrice;
}
)
.ToList();
}
private KeyValuePair<Reward, double>? GetRandomWeaponPresetWithinBudget(double roublesBudget, int rewardIndex)
@@ -325,7 +511,8 @@ public class RepeatableQuestRewardGenerator(
var defaultPresetPool = new ExhaustableArray<Preset>(
_presetHelper.GetDefaultWeaponPresets().Values.ToList(),
_randomUtil,
_cloner);
_cloner
);
while (defaultPresetPool.HasValues())
{
@@ -336,16 +523,19 @@ public class RepeatableQuestRewardGenerator(
}
// Gather all tpls so we can get prices of them
var tpls = randomPreset.Items.Select((item) => item.Template).ToList();
var tpls = randomPreset.Items.Select(item => item.Template).ToList();
// Does preset items fit our budget
var presetPrice = _itemHelper.GetItemAndChildrenPrice(tpls);
if (presetPrice <= roublesBudget)
{
_logger.Debug("Added weapon: ${ tpls[0]}with price: ${ presetPrice}");
_logger.Debug($"Added weapon: {tpls[0]}with price: {presetPrice}");
var chosenPreset = _cloner.Clone(randomPreset);
return new KeyValuePair<Reward, double>(GeneratePresetReward(chosenPreset.Encyclopedia, 1, rewardIndex, chosenPreset.Items), presetPrice);
return new KeyValuePair<Reward, double>(
GeneratePresetReward(chosenPreset.Encyclopedia, 1, rewardIndex, chosenPreset.Items),
presetPrice
);
}
}
@@ -354,7 +544,7 @@ public class RepeatableQuestRewardGenerator(
/**
* Helper to create a reward item structured as required by the client
*
*
* @param {string} tpl ItemId of the rewarded item
* @param {integer} count Amount of items to give
* @param {integer} index All rewards will be appended to a list, for unknown reasons the client wants the index
@@ -364,25 +554,26 @@ public class RepeatableQuestRewardGenerator(
protected Reward GeneratePresetReward(string tpl, int count, int index, List<Item>? preset, bool foundInRaid = true)
{
var id = _hashUtil.Generate();
var questRewardItem = new Reward{
var questRewardItem = new Reward
{
Id = _hashUtil.Generate(),
Unknown = false,
GameMode =[],
AvailableInGameEditions =[],
GameMode = [],
AvailableInGameEditions = [],
Index = index,
Target =id,
Target = id,
Value = count,
IsEncoded = false,
FindInRaid =foundInRaid,
Type =RewardType.Item,
Items =[],
FindInRaid = foundInRaid,
Type = RewardType.Item,
Items = []
};
// Get presets root item
var rootItem = preset.FirstOrDefault((item) => item.Template == tpl);
var rootItem = preset.FirstOrDefault(item => item.Template == tpl);
if (rootItem is null)
{
_logger.Warning($"Root item of preset: ${ tpl} not found");
_logger.Warning($"Root item of preset: {tpl} not found");
}
if (rootItem.Upd is not null)
@@ -396,10 +587,20 @@ public class RepeatableQuestRewardGenerator(
return questRewardItem;
}
private Reward GenerateItemReward(string tpl, double count, int index, bool foundInRaid = true)
/**
* Helper to create a reward item structured as required by the client
*
* @param {string} tpl ItemId of the rewarded item
* @param {integer} count Amount of items to give
* @param {integer} index All rewards will be appended to a list, for unknown reasons the client wants the index
* @param preset Optional array of preset items
* @returns {object} Object of "Reward"-item-type
*/
protected Reward GenerateItemReward(string tpl, double count, int index, bool foundInRaid = true)
{
var id = _hashUtil.Generate();
var questRewardItem = new Reward{
var questRewardItem = new Reward
{
Id = _hashUtil.Generate(),
Unknown = false,
GameMode = [],
@@ -410,10 +611,12 @@ public class RepeatableQuestRewardGenerator(
IsEncoded = false,
FindInRaid = foundInRaid,
Type = RewardType.Item,
Items = [],
Items = []
};
var rootItem = new Item { Id = id, Template = tpl, Upd = new Upd { StackObjectsCount = count, SpawnedInSession = foundInRaid }
var rootItem = new Item
{
Id = id, Template = tpl, Upd = new Upd { StackObjectsCount = count, SpawnedInSession = foundInRaid }
};
questRewardItem.Items = [rootItem];
@@ -428,13 +631,23 @@ public class RepeatableQuestRewardGenerator(
// Convert reward amount to Euros if necessary
var rewardAmountToGivePlayer =
currency == Money.EUROS ? _handbookHelper.FromRUB(rewardRoubles, Money.EUROS) : rewardRoubles;
currency == Money.EUROS ? _handbookHelper.FromRUB(rewardRoubles, Money.EUROS) : rewardRoubles;
// Get chosen currency + amount and return
return GenerateItemReward(currency, rewardAmountToGivePlayer, rewardIndex, false);
}
/**
* Picks rewardable items from items.json
* This means they must:
* - Fit into the inventory
* - Shouldn't be keys
* - Have a price greater than 0
* @param repeatableQuestConfig Config file
* @param traderId Id of trader who will give reward to player
* @returns List of rewardable items [[_tpl, itemTemplate],...]
*/
public List<TemplateItem> GetRewardableItems(RepeatableQuestConfig repeatableQuestConfig, string traderId)
{
// Get an array of seasonal items that should not be shown right now as seasonal event is not active
@@ -443,26 +656,43 @@ public class RepeatableQuestRewardGenerator(
// Check for specific baseclasses 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
// those are not in the game yet (e.g. AGS grenade launcher)
return _databaseService.GetItems().Values.Where((itemTemplate => {
// Base "Item" item has no parent, ignore it
if (itemTemplate.Parent == "")
{
return false;
}
return _databaseService.GetItems()
.Values.Where(
itemTemplate =>
{
// Base "Item" item has no parent, ignore it
if (itemTemplate.Parent == "")
{
return false;
}
if (seasonalItems.Contains(itemTemplate.Id))
{
return false;
}
if (seasonalItems.Contains(itemTemplate.Id))
{
return false;
}
var traderWhitelist = repeatableQuestConfig.TraderWhitelist.FirstOrDefault(
(trader) => trader.TraderId == traderId);
var traderWhitelist = repeatableQuestConfig.TraderWhitelist.FirstOrDefault(
trader => trader.TraderId == traderId
);
return IsValidRewardItem(itemTemplate.Id, repeatableQuestConfig, traderWhitelist?.RewardBaseWhitelist);
})).ToList();
return IsValidRewardItem(
itemTemplate.Id,
repeatableQuestConfig,
traderWhitelist?.RewardBaseWhitelist
);
}
)
.ToList();
}
private bool IsValidRewardItem(string tpl, RepeatableQuestConfig repeatableQuestConfig, List<string>? itemBaseWhitelist = null)
/**
* Checks if an id is a valid item. Valid meaning that it's an item that may be a reward
* or content of bot loot. Items that are tested as valid may be in a player backpack or stash.
* @param {string} tpl template id of item to check
* @returns True if item is valid reward
*/
private bool IsValidRewardItem(string tpl, RepeatableQuestConfig repeatableQuestConfig,
List<string>? itemBaseWhitelist = null)
{
// Return early if not valid item to give as reward
if (!_itemHelper.isValidItem(tpl))
@@ -482,7 +712,7 @@ public class RepeatableQuestRewardGenerator(
}
// Item has blacklisted base types
if (_itemHelper.IsOfBaseclasses(tpl, repeatableQuestConfig.RewardBaseTypeBlacklist ))
if (_itemHelper.IsOfBaseclasses(tpl, repeatableQuestConfig.RewardBaseTypeBlacklist))
{
return false;
}
+3 -3
View File
@@ -15,7 +15,7 @@ public class PresetHelper(
{
protected Dictionary<string, List<string>> _lookup = new();
protected Dictionary<string, Preset> _defaultEquipmentPresets;
protected Dictionary<string, Preset> _defaultWeaponPresets;
protected Dictionary<string, Preset>? _defaultWeaponPresets;
public void HydratePresetStore(Dictionary<string, List<string>> input)
{
@@ -40,10 +40,10 @@ public class PresetHelper(
*/
public Dictionary<string, Preset> GetDefaultWeaponPresets()
{
if (_defaultWeaponPresets == null)
if (_defaultWeaponPresets is null)
{
var tempPresets = _databaseService.GetGlobals().ItemPresets;
tempPresets = tempPresets.Where(
_defaultWeaponPresets = tempPresets.Where(
p =>
p.Value.Encyclopedia != null &&
_itemHelper.IsOfBaseclass(p.Value.Encyclopedia, BaseClasses.WEAPON)
@@ -1,9 +1,9 @@
using SptCommon.Annotations;
using Core.Models.Spt.Config;
using Core.Models.Utils;
using Core.Utils;
using Core.Utils.Cloners;
using Core.Utils.Collections;
using SptCommon.Annotations;
namespace Core.Helpers;
@@ -15,7 +15,7 @@ public class RepeatableQuestHelper(
)
{
/// <summary>
/// Get the relevant elimination config based on the current players PMC level
/// Get the relevant elimination config based on the current players PMC level
/// </summary>
/// <param name="pmcLevel">Level of PMC character</param>
/// <param name="repeatableConfig">Main repeatable config</param>
@@ -23,7 +23,7 @@ public class RepeatableQuestHelper(
public EliminationConfig? GetEliminationConfigByPmcLevel(int pmcLevel, RepeatableQuestConfig repeatableConfig)
{
return repeatableConfig.QuestConfig.Elimination.FirstOrDefault(
(x) => pmcLevel >= x.LevelRange.Min && pmcLevel <= x.LevelRange.Max
x => pmcLevel >= x.LevelRange.Min && pmcLevel <= x.LevelRange.Max
);
}
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace Core.Models.Spt.Repeatable;
@@ -14,7 +14,7 @@ public record QuestRewardValues
public double? RewardReputation { get; set; }
[JsonPropertyName("rewardNumItems")]
public double? RewardNumItems { get; set; }
public int? RewardNumItems { get; set; }
[JsonPropertyName("rewardRoubles")]
public double? RewardRoubles { get; set; }
@@ -1,49 +0,0 @@
using Core.Utils;
using Core.Utils.Cloners;
namespace Core.Models.Spt.Server;
public record ExhaustableArray<T>
{
private readonly RandomUtil _randomUtil;
private readonly ICloner _cloner;
private List<T> pool;
public ExhaustableArray(List<T> itemPool, RandomUtil randomUtil, ICloner cloner)
{
_randomUtil = randomUtil;
_cloner = cloner;
this.pool = _cloner.Clone(itemPool);
}
public T GetRandomValue()
{
if (pool == null || pool.Count == 0)
{
return default;
}
var index = _randomUtil.GetInt(0, pool.Count - 1);
T toReturn = _cloner.Clone(pool[index]);
pool.RemoveAt(index);
return toReturn;
}
public T GetFirstValue()
{
if (pool == null || pool.Count == 0)
{
return default;
}
T toReturn = _cloner.Clone(pool[0]);
pool.RemoveAt(0);
return toReturn;
}
public bool HasValues()
{
return pool?.Count > 0;
}
}
@@ -2,7 +2,7 @@ using Core.Utils.Cloners;
namespace Core.Utils.Collections;
public class ExhaustableArray<T> : IExhaustableArray<T>
public record ExhaustableArray<T> : IExhaustableArray<T>
{
private LinkedList<T>? pool;
private RandomUtil _randomUtil;