diff --git a/Libraries/Core/Helpers/ProfileHelper.cs b/Libraries/Core/Helpers/ProfileHelper.cs
index b29b77b5..9e0bf86b 100644
--- a/Libraries/Core/Helpers/ProfileHelper.cs
+++ b/Libraries/Core/Helpers/ProfileHelper.cs
@@ -559,17 +559,6 @@ public class ProfileHelper(
return pmcProfile?.Info?.Bans?.Any(b => b.BanType == BanType.RAGFAIR && currentTimestamp < b.DateTime) ?? false;
}
- ///
- /// Add an achievement to player profile
- ///
- /// Profile to add achievement to
- /// Id of achievement to add
- public void AddAchievementToProfile(SptProfile pmcProfile, string achievementId)
- {
- pmcProfile.CharacterData.PmcData.Achievements[achievementId] = _timeUtil.GetTimeStamp();
- // TODO: finish off implementation
- }
-
protected readonly List gameEditions = ["edge_of_darkness", "unheard_edition"];
public bool HasAccessToRepeatableFreeRefreshSystem(PmcData pmcProfile)
diff --git a/Libraries/Core/Helpers/QuestHelper.cs b/Libraries/Core/Helpers/QuestHelper.cs
index 07abbc40..41015092 100644
--- a/Libraries/Core/Helpers/QuestHelper.cs
+++ b/Libraries/Core/Helpers/QuestHelper.cs
@@ -27,6 +27,7 @@ public class QuestHelper(
LocaleService _localeService,
ProfileHelper _profileHelper,
QuestRewardHelper _questRewardHelper,
+ RewardHelper _rewardHelper,
LocalisationService _localisationService,
SeasonalEventService _seasonalEventService,
TraderHelper _traderHelper,
diff --git a/Libraries/Core/Helpers/QuestRewardHelper.cs b/Libraries/Core/Helpers/QuestRewardHelper.cs
index ae7d511a..b66f2ed4 100644
--- a/Libraries/Core/Helpers/QuestRewardHelper.cs
+++ b/Libraries/Core/Helpers/QuestRewardHelper.cs
@@ -26,6 +26,7 @@ public class QuestRewardHelper(
QuestConditionHelper _questConditionHelper,
ProfileHelper _profileHelper,
PresetHelper _presetHelper,
+ RewardHelper _rewardHelper,
LocalisationService _localisationService,
ICloner _cloner,
ConfigServer _configServer
@@ -67,104 +68,14 @@ public class QuestRewardHelper(
questDetails = ApplyMoneyBoost(questDetails, questMoneyRewardBonusMultiplier, state);
// e.g. 'Success' or 'AvailableForFinish'
- var questStateAsString = state.ToString();
- var gameVersion = pmcProfile.Info.GameVersion;
- var questRewards = (List?)questDetails.Rewards.GetType()
- .GetProperties()
- .FirstOrDefault(
- p =>
- p.Name == questStateAsString
- )
- .GetValue(questDetails.Rewards);
- foreach (var reward in questRewards)
- {
- if (!QuestRewardIsForGameEdition(reward, gameVersion))
- continue;
-
- SkillTypes skillType;
-
- if (SkillTypes.TryParse(reward.Target, out skillType))
- {
- _logger.Error($"Unable to get skill points for: {reward.Target}");
- continue;
- }
-
- switch (reward.Type)
- {
- case RewardType.Skill:
- _profileHelper.AddSkillPointsToPlayer(profileData, skillType, double.Parse((string)reward.Value));
- break;
- case RewardType.Experience: // this must occur first as the output object needs to take the modified profile exp value
- _profileHelper.AddExperienceToPmc(sessionId, int.Parse(reward.Target));
- break;
- case RewardType.TraderStanding:
- _traderHelper.AddStandingToTrader(sessionId, reward.Target, double.Parse((string)reward.Value));
- break;
- case RewardType.TraderUnlock:
- _traderHelper.SetTraderUnlockedState(reward.Target, true, sessionId);
- break;
- case RewardType.Item:
- // Handled by getQuestRewardItems() below
- break;
- case RewardType.AssortmentUnlock:
- // Handled by getAssort(), locked assorts are stripped out by `assortHelper.stripLockedLoyaltyAssort()` before being sent to player
- break;
- case RewardType.Achievement:
- _profileHelper.AddAchievementToProfile(fullProfile, reward.Target);
- break;
- case RewardType.StashRows: // Add specified stash rows from quest reward - requires client restart
- _profileHelper.AddStashRowsBonusToProfile(sessionId, int.Parse((string)reward.Value));
- break;
- case RewardType.ProductionScheme:
- FindAndAddHideoutProductionIdToProfile(pmcProfile, reward, questDetails, sessionId, questResponse);
- break;
- case RewardType.Pockets:
- _profileHelper.ReplaceProfilePocketTpl(pmcProfile, reward.Target);
- break;
- case RewardType.CustomizationDirect:
- _profileHelper.AddHideoutCustomisationUnlock(fullProfile, reward, CustomisationSource.UNLOCKED_IN_GAME);
- break;
- default:
- _logger.Error(
- _localisationService.GetText(
- "quest-reward_type_not_handled",
- new
- {
- rewardType = reward.Type,
- questId = questId,
- questName = questDetails.QuestName
- }
- )
- );
- break;
- }
- }
-
- return GetQuestRewardItems(questDetails, state, gameVersion);
- }
-
- /**
- * Does the provided quest reward have a game version requirement to be given and does it match
- * @param reward Reward to check
- * @param gameVersion Version of game to check reward against
- * @returns True if it has requirement, false if it doesnt pass check
- */
- public bool QuestRewardIsForGameEdition(Reward reward, string gameVersion)
- {
- if (reward?.AvailableInGameEditions?.Count > 0 && !reward.AvailableInGameEditions.Any(ge => ge == gameVersion))
- {
- // Reward has edition whitelist and game version isnt in it
- return false;
- }
-
- if (reward?.NotAvailableInGameEditions?.Count > 0 && reward.NotAvailableInGameEditions.Any(ge => ge == gameVersion))
- {
- // Reward has edition blacklist and game version is in it
- return false;
- }
-
- // No whitelist/blacklist or reward isnt blacklisted/whitelisted
- return true;
+ var rewards = questDetails.Rewards[state.ToString()];
+ return _rewardHelper.ApplyRewards(
+ rewards,
+ CustomisationSource.UNLOCKED_IN_GAME,
+ fullProfile,
+ profileData,
+ questId,
+ questResponse);
}
/**
@@ -226,7 +137,8 @@ public class QuestRewardHelper(
*/
public Quest ApplyMoneyBoost(Quest quest, double bonusPercent, QuestStatusEnum questStatus)
{
- var rewards = (List)quest.Rewards.GetType()
+ var clonedQuest = _cloner.Clone(quest);
+ var rewards = (List)clonedQuest.Rewards.GetType()
.GetProperties()
.FirstOrDefault(p => p.Name == questStatus.ToString())
.GetValue(quest.Rewards) ??
@@ -234,7 +146,7 @@ public class QuestRewardHelper(
var currencyRewards = rewards.Where(
r =>
r.Type.ToString() == "Item" &&
- _paymentHelper.IsMoneyTpl(r.Items[0].Template)
+ _paymentHelper.IsMoneyTpl(r.Items.FirstOrDefault().Template)
);
foreach (var reward in currencyRewards)
{
@@ -245,219 +157,6 @@ public class QuestRewardHelper(
reward.Value = newCurrencyAmount;
}
- return quest;
- }
-
- ///
- /// WIP - Find hideout craft id and add to unlockedProductionRecipe array in player profile
- /// also update client response recipeUnlocked array with craft id
- ///
- /// Player profile
- /// Reward item from quest with craft unlock details
- /// Quest with craft unlock reward
- /// Session id
- /// Response to send back to client
- protected void FindAndAddHideoutProductionIdToProfile(PmcData pmcData, Reward craftUnlockReward, Quest questDetails, string sessionID,
- ItemEventRouterResponse response)
- {
- var matchingProductions = GetRewardProductionMatch(craftUnlockReward, questDetails);
- if (matchingProductions.Count != 1)
- {
- _logger.Error(
- _localisationService.GetText(
- "quest-unable_to_find_matching_hideout_production",
- new
- {
- questName = questDetails.QuestName,
- matchCount = matchingProductions.Count
- }
- )
- );
-
- return;
- }
-
- // Add above match to pmc profile + client response
- var matchingCraftId = matchingProductions[0]?.Id;
- pmcData?.UnlockedInfo?.UnlockedProductionRecipe?.Add(matchingCraftId);
- response.ProfileChanges[sessionID].RecipeUnlocked[matchingCraftId] = true;
- }
-
- ///
- /// Find hideout craft for the specified quest reward
- ///
- /// Reward item from quest with craft unlock details
- /// Quest with craft unlock reward
- /// Hideout craft
- public List GetRewardProductionMatch(Reward craftUnlockReward, Quest questDetails)
- {
- // Get hideout crafts and find those that match by areatype/required level/end product tpl - hope for just one match
- var craftingRecipes = _databaseService.GetHideout().Production.Recipes;
-
- // Area that will be used to craft unlocked item
- var desiredHideoutAreaType = int.Parse((string)craftUnlockReward.TraderId);
-
- var matchingProductions = craftingRecipes.Where(
- p =>
- p.AreaType == desiredHideoutAreaType &&
- p.Requirements.Any(r => r.Type == "QuestComplete") &&
- p.Requirements.Any(r => r.RequiredLevel == craftUnlockReward.LoyaltyLevel) &&
- p.EndProduct == craftUnlockReward.Items[0].Template
- );
-
- // More/less than single match, above filtering wasn't strict enough
- if (matchingProductions.Count() != 1)
- matchingProductions = matchingProductions.Where(
- p =>
- p.Requirements.Any(
- r =>
- r.QuestId == questDetails.Id
- )
- );
-
- return matchingProductions.ToList();
- }
-
- /**
- * Gets a flat list of reward items for the given quest at a specific state for the specified game version (e.g. Fail/Success)
- * @param quest quest to get rewards for
- * @param status Quest status that holds the items (Started, Success, Fail)
- * @returns List of items with the correct maxStack
- */
- protected List- GetQuestRewardItems(Quest quest, QuestStatusEnum status, string gameVersion)
- {
- var rewards = (List)quest?.Rewards.GetType()
- .GetProperties()
- .FirstOrDefault(p => p.Name == status.ToString())
- .GetValue(quest.Rewards);
-
- if (rewards == null)
- return new();
-
- // Iterate over all rewards with the desired status, flatten out items that have a type of Item
- var questRewards = rewards.SelectMany(
- r =>
- r.Type.ToString() == "Item" &&
- QuestRewardIsForGameEdition(r, gameVersion)
- ? ProcessReward(r)
- : new()
- );
-
- return questRewards.ToList();
- }
-
- /**
- * Take reward item from quest and set FiR status + fix stack sizes + fix mod Ids
- * @param questReward Reward item to fix
- * @returns Fixed rewards
- */
- protected List
- ProcessReward(Reward reward)
- {
- // item with mods to return
- var rewardItems = new List
- ();
- var targets = new List
- ();
- var mods = new List
- ();
-
- // Is armor item that may need inserts / plates
- if (reward.Items.Count == 1 && _itemHelper.ArmorItemCanHoldMods(reward.Items[0].Template))
- {
- // Attempt to pull default preset from globals and add child items to reward (clones questReward.items)
- GenerateArmorRewardChildSlots(reward.Items[0], reward);
- }
-
- foreach (var rewardItem in reward.Items)
- {
- _itemHelper.AddUpdObjectToItem(rewardItem);
-
- // Reward items are granted Found in Raid status
- rewardItem.Upd.SpawnedInSession = true;
-
- // Is root item, fix stacks
- if (rewardItem.Id == reward.Type.ToString())
- {
- // Is base reward item
- if (rewardItem.ParentId != null &&
- rewardItem.ParentId == "hideout" &&
- rewardItem.Upd != null &&
- rewardItem.Upd.StackObjectsCount != null &&
- rewardItem.Upd.StackObjectsCount > 0)
- {
- rewardItem.Upd.StackObjectsCount = 1;
- }
-
- targets = _itemHelper.SplitStack(rewardItem);
- // splitStack created new ids for the new stacks. This would destroy the relation to possible children.
- // Instead, we reset the id to preserve relations and generate a new id in the downstream loop, where we are also reparenting if required
-
- foreach (var target in targets)
- {
- target.Id = rewardItem.Id;
- }
- }
- else
- {
- // Is child mod
- if (reward.Items[0].Upd.SpawnedInSession ?? false) // Propigate FiR status into child items
- rewardItem.Upd.SpawnedInSession = reward.Items[0].Upd.SpawnedInSession;
-
- mods.Add(rewardItem);
- }
- }
-
- // Add mods to the base items, fix ids
- foreach (var target in targets)
- {
- // This has all the original id relations since we reset the id to the original after the splitStack
- var itemsClone = new List
- { _cloner.Clone(target) };
- // Here we generate a new id for the root item
- target.Id = _hashUtil.Generate();
-
- foreach (var mod in mods)
- {
- itemsClone.Add(_cloner.Clone(mod));
- }
-
- rewardItems.AddRange(_itemHelper.ReparentItemAndChildren(target, itemsClone));
- }
-
- return rewardItems;
- }
-
- /**
- * Add missing mod items to a quest armor reward
- * @param originalRewardRootItem Original armor reward item from QuestReward.items object
- * @param questReward Armor reward from quest
- */
- protected void GenerateArmorRewardChildSlots(Item originalRewardRootItem, Reward reward)
- {
- // Look for a default preset from globals for armor
- var defaultPreset = _presetHelper.GetDefaultPreset(originalRewardRootItem.Template);
- if (defaultPreset != null)
- {
- // Found preset, use mods to hydrate reward item
- var presetAndMods = _itemHelper.ReplaceIDs(defaultPreset.Items);
- var newRootId = _itemHelper.RemapRootItemId(presetAndMods, _hashUtil.Generate());
-
- reward.Items = presetAndMods;
-
- // Find root item and set its stack count
- var rootItem = reward.Items.FirstOrDefault(i => i.Id == newRootId);
-
- // Remap target id to the new presets root id
- reward.Target = rootItem.Id;
-
- // Copy over stack count otherwise reward shows as missing in client
- _itemHelper.AddUpdObjectToItem(rootItem);
-
- rootItem.Upd.StackObjectsCount = originalRewardRootItem.Upd.StackObjectsCount;
-
- return;
- }
-
- _logger.Warning($"Unable to find default preset for armor {originalRewardRootItem.Template}, adding mods manually");
- var itemDbData = _itemHelper.GetItem(originalRewardRootItem.Template).Value;
-
- // Hydrate reward with only 'required' mods - necessary for things like helmets otherwise you end up with nvgs/visors etc
- reward.Items = _itemHelper.AddChildSlotItems(reward.Items, itemDbData, null, true);
+ return clonedQuest;
}
}
diff --git a/Libraries/Core/Helpers/RewardHelper.cs b/Libraries/Core/Helpers/RewardHelper.cs
new file mode 100644
index 00000000..2797e193
--- /dev/null
+++ b/Libraries/Core/Helpers/RewardHelper.cs
@@ -0,0 +1,442 @@
+using Core.Models.Eft.Common.Tables;
+using Core.Models.Eft.Common;
+using Core.Models.Eft.Hideout;
+using Core.Models.Eft.ItemEvent;
+using Core.Models.Eft.Profile;
+using Core.Models.Enums;
+using Core.Models.Utils;
+using Core.Services;
+using Core.Utils;
+using Core.Utils.Cloners;
+using SptCommon.Annotations;
+
+namespace Core.Helpers
+{
+ [Injectable]
+ public class RewardHelper
+ {
+ private readonly ISptLogger _logger;
+ private readonly HashUtil _hashUtil;
+ private readonly TimeUtil _timeUtil;
+ private readonly ItemHelper _itemHelper;
+ private readonly DatabaseService _databaseService;
+ private readonly ProfileHelper _profileHelper;
+ private readonly LocalisationService _localisationService;
+ private readonly TraderHelper _traderHelper;
+ private readonly PresetHelper _presetHelper;
+ private readonly ICloner _cloner;
+
+ public RewardHelper(
+ ISptLogger logger,
+ HashUtil hashUtil,
+ TimeUtil timeUtil,
+ ItemHelper itemHelper,
+ DatabaseService databaseService,
+ ProfileHelper profileHelper,
+ LocalisationService localisationService,
+ TraderHelper traderHelper,
+ PresetHelper presetHelper,
+ ICloner cloner
+ )
+ {
+ _logger = logger;
+ _hashUtil = hashUtil;
+ _timeUtil = timeUtil;
+ _itemHelper = itemHelper;
+ _databaseService = databaseService;
+ _profileHelper = profileHelper;
+ _localisationService = localisationService;
+ _traderHelper = traderHelper;
+ _presetHelper = presetHelper;
+ _cloner = cloner;
+ }
+
+ /**
+ * Apply the given rewards to the passed in profile
+ * @param rewards List of rewards to apply
+ * @param source The source of the rewards (Achievement, quest)
+ * @param fullProfile The full profile to apply the rewards to
+ * @param questId The quest or achievement ID, used for finding production unlocks
+ * @param questResponse Response to quest completion when a production is unlocked
+ * @returns List of items that were rewarded
+ */
+ public List
- ApplyRewards(
+ List rewards,
+ string source,
+ SptProfile fullProfile,
+ PmcData profileData,
+ string questId,
+ ItemEventRouterResponse questResponse = null
+ )
+ {
+ var sessionId = fullProfile?.ProfileInfo?.ProfileId;
+ var pmcProfile = fullProfile?.CharacterData.PmcData;
+ if (pmcProfile is null)
+ {
+ _logger.Error($"Unable to get pmc profile for: {sessionId}, no rewards given");
+ return [];
+ }
+
+ var gameVersion = pmcProfile.Info.GameVersion;
+
+ foreach (var reward in rewards)
+ {
+ // Handle reward availability for different game versions, notAvailableInGameEditions currently not used
+ if (!RewardIsForGameEdition(reward, gameVersion))
+ {
+ continue;
+ }
+
+ switch (reward.Type)
+ {
+ case RewardType.Skill:
+ // This needs to use the passed in profileData, as it could be the scav profile
+ _profileHelper.AddSkillPointsToPlayer(
+ profileData,
+ Enum.Parse(reward.Target),
+ reward.Value as double?
+ );
+ break;
+ case RewardType.Experience:
+ _profileHelper.AddExperienceToPmc(
+ sessionId,
+ (int)reward.Value
+ ); // this must occur first as the output object needs to take the modified profile exp value
+ break;
+ case RewardType.TraderStanding:
+ _traderHelper.AddStandingToTrader(
+ sessionId,
+ reward.Target,
+ (double)reward.Value
+ );
+ break;
+ case RewardType.TraderUnlock:
+ _traderHelper.SetTraderUnlockedState(reward.Target, true, sessionId);
+ break;
+ case RewardType.Item:
+ // Item rewards are retrieved by getRewardItems() below, and returned to be handled by caller
+ break;
+ case RewardType.AssortmentUnlock:
+ // Handled by getAssort(), locked assorts are stripped out by `assortHelper.stripLockedLoyaltyAssort()` before being sent to player
+ break;
+ case RewardType.Achievement:
+ AddAchievementToProfile(fullProfile, reward.Target);
+ break;
+ case RewardType.StashRows:
+ _profileHelper.AddStashRowsBonusToProfile(
+ sessionId,
+ (int)reward.Value
+ ); // Add specified stash rows from reward - requires client restart
+ break;
+ case RewardType.ProductionScheme:
+ FindAndAddHideoutProductionIdToProfile(pmcProfile, reward, questId, sessionId, questResponse);
+ break;
+ case RewardType.Pockets:
+ _profileHelper.ReplaceProfilePocketTpl(pmcProfile, reward.Target);
+ break;
+ case RewardType.CustomizationDirect:
+ _profileHelper.AddHideoutCustomisationUnlock(fullProfile, reward, source);
+ break;
+ default:
+ _logger.Error(
+ _localisationService.GetText(
+ "reward-type_not_handled",
+ new
+ {
+ rewardType = reward.Type,
+ questId = questId,
+ }
+ )
+ );
+ break;
+ }
+ }
+
+ return GetRewardItems(rewards, gameVersion);
+ }
+
+ /**
+ * Does the provided reward have a game version requirement to be given and does it match
+ * @param reward Reward to check
+ * @param gameVersion Version of game to check reward against
+ * @returns True if it has requirement, false if it doesnt pass check
+ */
+ public bool RewardIsForGameEdition(Reward reward, string gameVersion)
+ {
+ if (reward.AvailableInGameEditions?.Count > 0 && !reward.AvailableInGameEditions.Contains(gameVersion))
+ {
+ // Reward has edition whitelist and game version isn't in it
+ return false;
+ }
+
+ if (reward.NotAvailableInGameEditions?.Count > 0 &&
+ reward.NotAvailableInGameEditions.Contains(gameVersion))
+ {
+ // Reward has edition blacklist and game version is in it
+ return false;
+ }
+
+ // No whitelist/blacklist or reward isn't blacklisted/whitelisted
+ return true;
+ }
+
+ /**
+ * WIP - Find hideout craft id and add to unlockedProductionRecipe array in player profile
+ * also update client response recipeUnlocked array with craft id
+ * @param pmcData Player profile
+ * @param craftUnlockReward Reward with craft unlock details
+ * @param questId Quest or achievement ID with craft unlock reward
+ * @param sessionID Session id
+ * @param response Response to send back to client
+ */
+ protected void FindAndAddHideoutProductionIdToProfile(
+ PmcData pmcData,
+ Reward craftUnlockReward,
+ string questId,
+ string sessionID,
+ ItemEventRouterResponse response)
+ {
+ var matchingProductions = GetRewardProductionMatch(craftUnlockReward, questId);
+ if (matchingProductions.Count != 1)
+ {
+ _logger.Error(
+ _localisationService.GetText(
+ "reward-unable_to_find_matching_hideout_production",
+ new
+ {
+ questId = questId,
+ matchCount = matchingProductions.Count,
+ }
+ )
+ );
+
+ return;
+ }
+
+ // Add above match to pmc profile + client response
+ var matchingCraftId = matchingProductions[0].Id;
+ pmcData.UnlockedInfo.UnlockedProductionRecipe.Add(matchingCraftId);
+ if (response is not null)
+ {
+ response.ProfileChanges[sessionID].RecipeUnlocked[matchingCraftId] = true;
+ }
+ }
+
+ /**
+ * Find hideout craft for the specified reward
+ * @param craftUnlockReward Reward with craft unlock details
+ * @param questId Quest or achievement ID with craft unlock reward
+ * @returns Hideout craft
+ */
+ public List GetRewardProductionMatch(Reward craftUnlockReward, string questId)
+ {
+ // Get hideout crafts and find those that match by areatype/required level/end product tpl - hope for just one match
+ var craftingRecipes = _databaseService.GetHideout().Production.Recipes;
+
+ // Area that will be used to craft unlocked item
+ var desiredHideoutAreaType = int.Parse(craftUnlockReward.TraderId.ToString());
+
+ var matchingProductions = craftingRecipes.Where(
+ (prod) =>
+ prod.AreaType == desiredHideoutAreaType &&
+ //prod.requirements.some((requirement) => requirement.questId == questId) && // BSG don't store the quest id in requirement any more!
+ prod.Requirements.Any((requirement) => requirement.Type == "QuestComplete") &&
+ prod.Requirements.Any(
+ (requirement) => requirement.RequiredLevel == craftUnlockReward.LoyaltyLevel
+ ) &&
+ prod.EndProduct == craftUnlockReward.Items.FirstOrDefault().Template
+ )
+ .ToList();
+
+ // More/less than single match, above filtering wasn't strict enough
+ if (matchingProductions.Count() != 1)
+ {
+ // Multiple matches were found, last ditch attempt to match by questid (value we add manually to production.json via `gen:productionquests` command)
+ matchingProductions = matchingProductions.Where(
+ (prod) =>
+ prod.Requirements.Any((requirement) => requirement.QuestId == questId)
+ )
+ .ToList();
+ }
+
+ return matchingProductions;
+ }
+
+ /**
+ * Gets a flat list of reward items from the given rewards for the specified game version
+ * @param rewards Array of rewards to get the items from
+ * @param gameVersion The game version of the profile
+ * @returns array of items with the correct maxStack
+ */
+ protected List
- GetRewardItems(List rewards, string gameVersion)
+ {
+ // Iterate over all rewards with the desired status, flatten out items that have a type of Item
+ var rewardItems = rewards.SelectMany(
+ (reward) =>
+ reward.Type == RewardType.Item && RewardIsForGameEdition(reward, gameVersion)
+ ? ProcessReward(reward)
+ : []
+ );
+
+ return rewardItems.ToList();
+ }
+
+ /**
+ * Take reward item and set FiR status + fix stack sizes + fix mod Ids
+ * @param reward Reward item to fix
+ * @returns Fixed rewards
+ */
+ protected List
- ProcessReward(Reward reward)
+ {
+ /** item with mods to return */
+ List
- rewardItems = [];
+ List
- targets = [];
+ List
- mods = [];
+
+ // Is armor item that may need inserts / plates
+ if (reward.Items.Count == 1 && _itemHelper.ArmorItemCanHoldMods(reward.Items[0].Template))
+ {
+ // Only process items with slots
+ if (_itemHelper.ItemHasSlots(reward.Items.FirstOrDefault().Template))
+ {
+ // Attempt to pull default preset from globals and add child items to reward (clones reward.items)
+ GenerateArmorRewardChildSlots(reward.Items.FirstOrDefault(), reward);
+ }
+ }
+
+ foreach (var rewardItem in reward.Items)
+ {
+ _itemHelper.AddUpdObjectToItem(rewardItem);
+
+ // Reward items are granted Found in Raid status
+ rewardItem.Upd.SpawnedInSession = true;
+
+ // Is root item, fix stacks
+ if (rewardItem.Id == reward.Target)
+ {
+ // Is base reward item
+ if (
+ rewardItem.ParentId != null &&
+ rewardItem.ParentId == "hideout" && // Has parentId of hideout
+ rewardItem.Upd != null &&
+ rewardItem.Upd.StackObjectsCount != null && // Has upd with stackobject count
+ rewardItem.Upd.StackObjectsCount > 1 // More than 1 item in stack
+ )
+ {
+ rewardItem.Upd.StackObjectsCount = 1;
+ }
+
+ targets = _itemHelper.SplitStack(rewardItem);
+ // splitStack created new ids for the new stacks. This would destroy the relation to possible children.
+ // Instead, we reset the id to preserve relations and generate a new id in the downstream loop, where we are also reparenting if required
+ foreach (var target in targets)
+ {
+ target.Id = rewardItem.Id;
+ }
+ }
+ else
+ {
+ // Is child mod
+ if (reward.Items.FirstOrDefault().Upd.SpawnedInSession.GetValueOrDefault(false))
+ {
+ // Propigate FiR status into child items
+ rewardItem.Upd.SpawnedInSession = reward.Items.FirstOrDefault()?.Upd.SpawnedInSession;
+ }
+
+ mods.Add(rewardItem);
+ }
+ }
+
+ // Add mods to the base items, fix ids
+ foreach (var target in targets)
+ {
+ // This has all the original id relations since we reset the id to the original after the splitStack
+ var itemsClone = new List
- { _cloner.Clone(target) };
+ // Here we generate a new id for the root item
+ target.Id = _hashUtil.Generate();
+
+ foreach (var mod in mods)
+ {
+ itemsClone.Add(_cloner.Clone(mod));
+ }
+
+ rewardItems.AddRange(rewardItems.Concat(_itemHelper.ReparentItemAndChildren(target, itemsClone)));
+ }
+
+ return rewardItems;
+ }
+
+ /**
+ * Add missing mod items to an armor reward
+ * @param originalRewardRootItem Original armor reward item from IReward.items object
+ * @param reward Armor reward
+ */
+ protected void GenerateArmorRewardChildSlots(Item originalRewardRootItem, Reward reward)
+ {
+ // Look for a default preset from globals for armor
+ var defaultPreset = _presetHelper.GetDefaultPreset(originalRewardRootItem.Template);
+ if (defaultPreset is not null)
+ {
+ // Found preset, use mods to hydrate reward item
+ var presetAndMods = _itemHelper.ReplaceIDs(defaultPreset.Items);
+ var newRootId = _itemHelper.RemapRootItemId(presetAndMods);
+
+ reward.Items = presetAndMods;
+
+ // Find root item and set its stack count
+ var rootItem = reward.Items.FirstOrDefault((item) => item.Id == newRootId);
+
+ // Remap target id to the new presets root id
+ reward.Target = rootItem.Id;
+
+ // Copy over stack count otherwise reward shows as missing in client
+ _itemHelper.AddUpdObjectToItem(rootItem);
+ rootItem.Upd.StackObjectsCount = originalRewardRootItem.Upd.StackObjectsCount;
+ return;
+ }
+
+ _logger.Warning(
+ "Unable to find default preset for armor {originalRewardRootItem._tpl}, adding mods manually"
+ );
+ var itemDbData = _itemHelper.GetItem(originalRewardRootItem.Template).Value;
+
+ // Hydrate reward with only 'required' mods - necessary for things like helmets otherwise you end up with nvgs/visors etc
+ reward.Items = _itemHelper.AddChildSlotItems(reward.Items, itemDbData, null, true);
+ }
+
+ /**
+ * Add an achievement to player profile and handle any rewards for the achievement
+ * Triggered from a quest, or another achievement
+ * @param fullProfile Profile to add achievement to
+ * @param achievementId Id of achievement to add
+ */
+ public void AddAchievementToProfile(SptProfile fullProfile, string achievementId)
+ {
+ // Add achievement id to profile with timestamp it was unlocked
+ fullProfile.CharacterData.PmcData.Achievements[achievementId] = _timeUtil.GetTimeStamp();
+
+ // Check for any customisation unlocks
+ var achievementDataDb = _databaseService
+ .GetTemplates()
+ .Achievements.FirstOrDefault((achievement) => achievement.Id == achievementId);
+ if (achievementDataDb is null)
+ {
+ return;
+ }
+
+ // Note: At the moment, we don't know the exact quest and achievement data layout for an achievement
+ // that is triggered by a quest, that gives an item, because BSG has only done this once. However
+ // based on deduction, I am going to assume that the *quest* will handle the initial item reward,
+ // and the achievement reward should only be handled post-wipe.
+ // All of that is to say, we are going to ignore the list of returned reward items here
+ var pmcProfile = fullProfile.CharacterData.PmcData;
+ ApplyRewards(
+ achievementDataDb.Rewards,
+ CustomisationSource.ACHIEVEMENT,
+ fullProfile,
+ pmcProfile,
+ achievementDataDb.Id
+ );
+ }
+ }
+}
diff --git a/Libraries/Core/Models/Eft/Common/Tables/Quest.cs b/Libraries/Core/Models/Eft/Common/Tables/Quest.cs
index b5842e39..1b3c12b1 100644
--- a/Libraries/Core/Models/Eft/Common/Tables/Quest.cs
+++ b/Libraries/Core/Models/Eft/Common/Tables/Quest.cs
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using Core.Models.Enums;
+using SptCommon.Extensions;
namespace Core.Models.Eft.Common.Tables;
@@ -492,4 +493,17 @@ public record QuestRewards
[JsonPropertyName("Expired")]
public List? Expired { get; set; }
+
+ public List this[string propName]
+ {
+ get
+ {
+ var matchingProp = GetType()
+ .GetProperties()
+ .SingleOrDefault(p => p.GetJsonName() == propName)
+ ?.GetValue(this);
+
+ return (List)matchingProp;
+ }
+ }
}
diff --git a/Libraries/Core/Services/LocationLifecycleService.cs b/Libraries/Core/Services/LocationLifecycleService.cs
index 1f668886..4530df81 100644
--- a/Libraries/Core/Services/LocationLifecycleService.cs
+++ b/Libraries/Core/Services/LocationLifecycleService.cs
@@ -1,13 +1,26 @@
-using SptCommon.Annotations;
+using Core.Helpers;
+using SptCommon.Annotations;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
using Core.Models.Eft.Match;
+using Core.Models.Utils;
namespace Core.Services;
[Injectable(InjectionType.Singleton)]
public class LocationLifecycleService
{
+ private readonly ISptLogger _logger;
+ private readonly RewardHelper _rewardHelper;
+
+ public LocationLifecycleService(
+ ISptLogger logger,
+ RewardHelper rewardHelper)
+ {
+ _logger = logger;
+ _rewardHelper = rewardHelper;
+ }
+
/** Handle client/match/local/start */
public void StartLocalRaid(string sessionId, StartLocalRaidRequestData request)
{
diff --git a/Libraries/Core/Services/ProfileFixerService.cs b/Libraries/Core/Services/ProfileFixerService.cs
index 5951823a..5ee0bc06 100644
--- a/Libraries/Core/Services/ProfileFixerService.cs
+++ b/Libraries/Core/Services/ProfileFixerService.cs
@@ -18,7 +18,7 @@ public class ProfileFixerService(
HashUtil _hashUtil,
JsonUtil _jsonUtil,
ItemHelper _itemHelper,
- QuestRewardHelper _questRewardHelper,
+ RewardHelper _rewardHelper,
TraderHelper _traderHelper,
HideoutHelper _hideoutHelper,
DatabaseService _databaseService,
@@ -369,9 +369,9 @@ public class ProfileFixerService(
/// The quest the reward belongs to
protected void VerifyQuestProductionUnlock(PmcData pmcProfile, Reward productionUnlockReward, Quest questDetails)
{
- var matchingProductions = _questRewardHelper.GetRewardProductionMatch(
+ var matchingProductions = _rewardHelper.GetRewardProductionMatch(
productionUnlockReward,
- questDetails
+ questDetails.Id
);
if (matchingProductions.Count != 1)