From b541c04bac1dd5d1576db0f19c8b22157ddd343b Mon Sep 17 00:00:00 2001 From: DrakiaXYZ <565558+TheDgtl@users.noreply.github.com> Date: Sun, 19 Jan 2025 22:41:17 -0800 Subject: [PATCH] Refactor quest and achievement reward handling to be more centralized - Create a new RewardHelper class that contains the majority of shared reward handling - Move a lot of methods from QuestRewardHelper into RewardHelper - Fix a bug in `applyMoneyBoost` that could result in compounding money boost being applied across characters - Route achievement reward handling through RewardHelper --- .../assets/database/locales/server/en.json | 4 +- project/src/di/Container.ts | 2 + project/src/helpers/ProfileHelper.ts | 44 -- project/src/helpers/QuestHelper.ts | 4 +- project/src/helpers/QuestRewardHelper.ts | 312 +-------------- project/src/helpers/RewardHelper.ts | 375 ++++++++++++++++++ .../src/services/LocationLifecycleService.ts | 42 +- project/src/services/ProfileFixerService.ts | 8 +- 8 files changed, 427 insertions(+), 364 deletions(-) create mode 100644 project/src/helpers/RewardHelper.ts diff --git a/project/assets/database/locales/server/en.json b/project/assets/database/locales/server/en.json index af5539a1..6857cba2 100644 --- a/project/assets/database/locales/server/en.json +++ b/project/assets/database/locales/server/en.json @@ -594,9 +594,7 @@ "quest-handover_wrong_item": "Unable to hand item in for quest: {{questId}}, expected tpl: {{requiredTpl}} but handed in: {{handedInTpl}}", "quest-item_not_found_in_inventory": "changeItemStack() Item with _id: %s not found in inventory", "quest-no_skill_found": "Skill %s not found", - "quest-reward_type_not_handled": "Quest reward type: {{rewardType}} not handled for quest: {{questId}} name: {{questName}}", "quest-unable_to_find_compare_condition": "Unrecognised Comparison Method: %s", - "quest-unable_to_find_matching_hideout_production": "Unable to find matching hideout craft unlock for quest: {{questName}}, matches found: {{matchCount}}", "quest-unable_to_find_quest_in_db": "Quest id: {{questId}} with type: {{questType}} not found in database", "quest-unable_to_find_quest_in_db_no_quest_rewards": "Unable to find quest: %s in db, unable to give quest rewards to player", "quest-unable_to_find_repeatable_to_replace": "Unable to find repeatable quest in profile to replace, skipping", @@ -651,6 +649,8 @@ "repeatable-quest_handover_failed_condition_invalid": "Quest handover error: condition not found or incorrect value. qid: {{body.qid}}, condition: {{body.conditionId}}", "repeatable-unable_to_accept_quest_see_log": "Unable to accept quest, see server log for details", "repeatable-unable_to_accept_quest_starting_message_not_found": "Unable to accept quest: {{questId}} cant find quest started message text with id: {{messageId}}", + "reward-type_not_handled": "Reward type: {{rewardType}} not handled for quest/achievement: {{questId}}", + "reward-unable_to_find_matching_hideout_production": "Unable to find matching hideout craft unlock for quest/achievement: {{questId}}, matches found: {{matchCount}}", "route_onupdate_no_response": "onUpdate: %s route doesn't report success or fail", "scav-missing_karma_level_getting_default": "getScavKarmaLevel() failed, unable to find fence in profile.traderInfo. Defaulting to karma level 0", "scav-missing_karma_settings": "Unable to get karma settings for level %s", diff --git a/project/src/di/Container.ts b/project/src/di/Container.ts index 65064fe8..a2c47ef4 100644 --- a/project/src/di/Container.ts +++ b/project/src/di/Container.ts @@ -120,6 +120,7 @@ import { RagfairSellHelper } from "@spt/helpers/RagfairSellHelper"; import { RagfairServerHelper } from "@spt/helpers/RagfairServerHelper"; import { RagfairSortHelper } from "@spt/helpers/RagfairSortHelper"; import { RepairHelper } from "@spt/helpers/RepairHelper"; +import { RewardHelper } from "@spt/helpers/RewardHelper"; import { RepeatableQuestHelper } from "@spt/helpers/RepeatableQuestHelper"; import { SecureContainerHelper } from "@spt/helpers/SecureContainerHelper"; import { TradeHelper } from "@spt/helpers/TradeHelper"; @@ -616,6 +617,7 @@ export class Container { depContainer.register("RagfairOfferHelper", { useClass: RagfairOfferHelper }); depContainer.register("RagfairServerHelper", { useClass: RagfairServerHelper }); depContainer.register("RepairHelper", { useClass: RepairHelper }); + depContainer.register("RewardHelper", { useClass: RewardHelper }); depContainer.register("TraderHelper", TraderHelper); depContainer.register("TraderAssortHelper", TraderAssortHelper, { lifecycle: Lifecycle.Singleton, diff --git a/project/src/helpers/ProfileHelper.ts b/project/src/helpers/ProfileHelper.ts index 2aabebe8..043709e6 100644 --- a/project/src/helpers/ProfileHelper.ts +++ b/project/src/helpers/ProfileHelper.ts @@ -547,50 +547,6 @@ export class ProfileHelper { return pmcProfile.Info.Bans.some((ban) => ban.banType === BanType.RAGFAIR && currentTimestamp < ban.dateTime); } - /** - * Add an achievement to player profile + check for and add any hideout customisation unlocks to profile - * @param fullProfile Profile to add achievement to - * @param achievementId Id of achievement to add - */ - public addAchievementToProfile(fullProfile: ISptProfile, achievementId: string): void { - // Add achievement id to profile with timestamp it was unlocked - fullProfile.characters.pmc.Achievements[achievementId] = this.timeUtil.getTimestamp(); - - // Check for any customisation unlocks - const achievementDataDb = this.databaseService - .getTemplates() - .achievements.find((achievement) => achievement.id === achievementId); - if (!achievementDataDb) { - return; - } - - // Get customisation reward object from achievement db - const customizationDirectReward = achievementDataDb.rewards.find( - (reward) => reward.type === "CustomizationDirect", - ); - if (!customizationDirectReward) { - return; - } - - const customisationDataDb = this.databaseService - .getHideout() - .customisation.globals.find((customisation) => customisation.itemId === customizationDirectReward.target); - if (!customisationDataDb) { - this.logger.error( - `Unable to find customisation data for ${customizationDirectReward.target} in profile ${fullProfile.info.id}`, - ); - - return; - } - - // Reward found, add to profile - fullProfile.customisationUnlocks.push({ - id: customizationDirectReward.target, - source: CustomisationSource.ACHIEVEMENT, - type: customisationDataDb.type as CustomisationType, - }); - } - public hasAccessToRepeatableFreeRefreshSystem(pmcProfile: IPmcData): boolean { return [GameEditions.EDGE_OF_DARKNESS, GameEditions.UNHEARD].includes(pmcProfile.Info?.GameVersion); } diff --git a/project/src/helpers/QuestHelper.ts b/project/src/helpers/QuestHelper.ts index 03661b87..2ad57aaf 100644 --- a/project/src/helpers/QuestHelper.ts +++ b/project/src/helpers/QuestHelper.ts @@ -2,6 +2,7 @@ import { ItemHelper } from "@spt/helpers/ItemHelper"; import { ProfileHelper } from "@spt/helpers/ProfileHelper"; import { QuestConditionHelper } from "@spt/helpers/QuestConditionHelper"; import { QuestRewardHelper } from "@spt/helpers/QuestRewardHelper"; +import { RewardHelper } from "@spt/helpers/RewardHelper"; import { TraderHelper } from "@spt/helpers/TraderHelper"; import { IPmcData } from "@spt/models/eft/common/IPmcData"; import { Common, IQuestStatus } from "@spt/models/eft/common/tables/IBotBase"; @@ -46,6 +47,7 @@ export class QuestHelper { @inject("LocaleService") protected localeService: LocaleService, @inject("ProfileHelper") protected profileHelper: ProfileHelper, @inject("QuestRewardHelper") protected questRewardHelper: QuestRewardHelper, + @inject("RewardHelper") protected rewardHelper: RewardHelper, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService, @inject("TraderHelper") protected traderHelper: TraderHelper, @@ -1060,7 +1062,7 @@ export class QuestHelper { // Remove any reward that doesn't pass the game edition check for (const rewardType of Object.keys(quest.rewards)) { quest.rewards[rewardType] = quest.rewards[rewardType].filter((reward: IReward) => - this.questRewardHelper.questRewardIsForGameEdition(reward, gameVersion), + this.rewardHelper.rewardIsForGameEdition(reward, gameVersion), ); } } diff --git a/project/src/helpers/QuestRewardHelper.ts b/project/src/helpers/QuestRewardHelper.ts index 8eeb7fcc..f4423a23 100644 --- a/project/src/helpers/QuestRewardHelper.ts +++ b/project/src/helpers/QuestRewardHelper.ts @@ -4,14 +4,11 @@ import { PresetHelper } from "@spt/helpers/PresetHelper"; import { ProfileHelper } from "@spt/helpers/ProfileHelper"; import { TraderHelper } from "@spt/helpers/TraderHelper"; import { IPmcData } from "@spt/models/eft/common/IPmcData"; -import { CustomisationSource } from "@spt/models/eft/common/tables/ICustomisationStorage"; import { IItem } from "@spt/models/eft/common/tables/IItem"; import { IQuest } from "@spt/models/eft/common/tables/IQuest"; import { IReward } from "@spt/models/eft/common/tables/IReward"; -import { IHideoutProduction } from "@spt/models/eft/hideout/IHideoutProduction"; import { IItemEventRouterResponse } from "@spt/models/eft/itemEvent/IItemEventRouterResponse"; import { QuestStatus } from "@spt/models/enums/QuestStatus"; -import { RewardType } from "@spt/models/enums/RewardType"; import { SkillTypes } from "@spt/models/enums/SkillTypes"; import type { ILogger } from "@spt/models/spt/utils/ILogger"; import { DatabaseService } from "@spt/services/DatabaseService"; @@ -19,6 +16,8 @@ import { LocalisationService } from "@spt/services/LocalisationService"; import { HashUtil } from "@spt/utils/HashUtil"; import type { ICloner } from "@spt/utils/cloners/ICloner"; import { inject, injectable } from "tsyringe"; +import { RewardHelper } from "@spt/helpers/RewardHelper"; +import { CustomisationSource } from "@spt/models/eft/common/tables/ICustomisationStorage"; @injectable() export class QuestRewardHelper { @@ -33,6 +32,7 @@ export class QuestRewardHelper { @inject("TraderHelper") protected traderHelper: TraderHelper, @inject("PresetHelper") protected presetHelper: PresetHelper, @inject("PrimaryCloner") protected cloner: ICloner, + @inject("RewardHelper") protected rewardHelper: RewardHelper, ) {} /** @@ -78,99 +78,15 @@ export class QuestRewardHelper { // e.g. 'Success' or 'AvailableForFinish' const questStateAsString = QuestStatus[state]; - const gameVersion = pmcProfile.Info.GameVersion; - for (const reward of questDetails.rewards[questStateAsString]) { - // Handle quest reward availability for different game versions, notAvailableInGameEditions currently not used - if (!this.questRewardIsForGameEdition(reward, gameVersion)) { - continue; - } - - switch (reward.type) { - case RewardType.SKILL: - this.profileHelper.addSkillPointsToPlayer( - profileData, - reward.target as SkillTypes, - Number(reward.value), - ); - break; - case RewardType.EXPERIENCE: - this.profileHelper.addExperienceToPmc(sessionId, Number.parseInt(reward.value)); // this must occur first as the output object needs to take the modified profile exp value - break; - case RewardType.TRADER_STANDING: - this.traderHelper.addStandingToTrader( - sessionId, - reward.target, - Number.parseFloat(reward.value), - ); - break; - case RewardType.TRADER_UNLOCK: - this.traderHelper.setTraderUnlockedState(reward.target, true, sessionId); - break; - case RewardType.ITEM: - // Handled by getQuestRewardItems() below - break; - case RewardType.ASSORTMENT_UNLOCK: - // Handled by getAssort(), locked assorts are stripped out by `assortHelper.stripLockedLoyaltyAssort()` before being sent to player - break; - case RewardType.ACHIEVEMENT: - this.profileHelper.addAchievementToProfile(fullProfile, reward.target); - break; - case RewardType.STASH_ROWS: - this.profileHelper.addStashRowsBonusToProfile(sessionId, Number.parseInt(reward.value)); // Add specified stash rows from quest reward - requires client restart - break; - case RewardType.PRODUCTIONS_SCHEME: - this.findAndAddHideoutProductionIdToProfile( - pmcProfile, - reward, - questDetails, - sessionId, - questResponse, - ); - break; - case RewardType.POCKETS: - this.profileHelper.replaceProfilePocketTpl(pmcProfile, reward.target); - break; - case RewardType.CUSTOMIZATION_DIRECT: - this.profileHelper.addHideoutCustomisationUnlock( - fullProfile, - reward, - CustomisationSource.UNLOCKED_IN_GAME, - ); - break; - default: - this.logger.error( - this.localisationService.getText("quest-reward_type_not_handled", { - rewardType: reward.type, - questId: questId, - questName: questDetails.QuestName, - }), - ); - break; - } - } - - return this.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 questRewardIsForGameEdition(reward: IReward, gameVersion: string): boolean { - if (reward.availableInGameEditions?.length > 0 && !reward.availableInGameEditions?.includes(gameVersion)) { - // Reward has edition whitelist and game version isnt in it - return false; - } - - if (reward.notAvailableInGameEditions?.length > 0 && reward.notAvailableInGameEditions?.includes(gameVersion)) { - // Reward has edition blacklist and game version is in it - return false; - } - - // No whitelist/blacklist or reward isnt blacklisted/whitelisted - return true; + const rewards = questDetails.rewards[questStateAsString]; + return this.rewardHelper.applyRewards( + rewards, + CustomisationSource.UNLOCKED_IN_GAME, + fullProfile, + profileData, + questId, + questResponse, + ); } /** @@ -228,7 +144,8 @@ export class QuestRewardHelper { * @returns Updated quest */ public applyMoneyBoost(quest: IQuest, bonusPercent: number, questStatus: QuestStatus): IQuest { - const rewards: IReward[] = quest.rewards?.[QuestStatus[questStatus]] ?? []; + const clonedQuest = this.cloner.clone(quest); + const rewards: IReward[] = clonedQuest.rewards?.[QuestStatus[questStatus]] ?? []; const currencyRewards = rewards.filter( (reward) => reward.type === "Item" && this.paymentHelper.isMoneyTpl(reward.items[0]._tpl), ); @@ -240,205 +157,6 @@ export 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 - * @param pmcData Player profile - * @param craftUnlockReward Reward item from quest with craft unlock details - * @param questDetails Quest with craft unlock reward - * @param sessionID Session id - * @param response Response to send back to client - */ - protected findAndAddHideoutProductionIdToProfile( - pmcData: IPmcData, - craftUnlockReward: IReward, - questDetails: IQuest, - sessionID: string, - response: IItemEventRouterResponse, - ): void { - const matchingProductions = this.getRewardProductionMatch(craftUnlockReward, questDetails); - if (matchingProductions.length !== 1) { - this.logger.error( - this.localisationService.getText("quest-unable_to_find_matching_hideout_production", { - questName: questDetails.QuestName, - matchCount: matchingProductions.length, - }), - ); - - return; - } - - // Add above match to pmc profile + client response - const matchingCraftId = matchingProductions[0]._id; - pmcData.UnlockedInfo.unlockedProductionRecipe.push(matchingCraftId); - response.profileChanges[sessionID].recipeUnlocked[matchingCraftId] = true; - } - - /** - * Find hideout craft for the specified quest reward - * @param craftUnlockReward Reward item from quest with craft unlock details - * @param questDetails Quest with craft unlock reward - * @returns Hideout craft - */ - public getRewardProductionMatch(craftUnlockReward: IReward, questDetails: IQuest): IHideoutProduction[] { - // Get hideout crafts and find those that match by areatype/required level/end product tpl - hope for just one match - const craftingRecipes = this.databaseService.getHideout().production.recipes; - - // Area that will be used to craft unlocked item - const desiredHideoutAreaType = Number.parseInt(craftUnlockReward.traderId); - - let matchingProductions = craftingRecipes.filter( - (prod) => - prod.areaType === desiredHideoutAreaType && - //prod.requirements.some((requirement) => requirement.questId === questDetails._id) && // BSG dont store the quest id in requirement any more! - prod.requirements.some((requirement) => requirement.type === "QuestComplete") && - prod.requirements.some((requirement) => requirement.requiredLevel === craftUnlockReward.loyaltyLevel) && - prod.endProduct === craftUnlockReward.items[0]._tpl, - ); - - // More/less than single match, above filtering wasn't strict enough - if (matchingProductions.length !== 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.filter((prod) => - prod.requirements.some((requirement) => requirement.questId === questDetails._id), - ); - } - - return matchingProductions; - } - - /** - * 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 array of items with the correct maxStack - */ - protected getQuestRewardItems(quest: IQuest, status: QuestStatus, gameVersion: string): IItem[] { - if (!quest.rewards[QuestStatus[status]]) { - this.logger.warning(`Unable to find: ${status} reward for quest: ${quest.QuestName}`); - return []; - } - - // Iterate over all rewards with the desired status, flatten out items that have a type of Item - const questRewards = quest.rewards[QuestStatus[status]].flatMap((reward: IReward) => - reward.type === "Item" && this.questRewardIsForGameEdition(reward, gameVersion) - ? this.processReward(reward) - : [], - ); - - return questRewards; - } - - /** - * 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 processReward(questReward: IReward): IItem[] { - /** item with mods to return */ - let rewardItems: IItem[] = []; - let targets: IItem[] = []; - const mods: IItem[] = []; - - // Is armor item that may need inserts / plates - if (questReward.items.length === 1 && this.itemHelper.armorItemCanHoldMods(questReward.items[0]._tpl)) { - // Only process items with slots - if (this.itemHelper.itemHasSlots(questReward.items[0]._tpl)) { - // Attempt to pull default preset from globals and add child items to reward (clones questReward.items) - this.generateArmorRewardChildSlots(questReward.items[0], questReward); - } - } - - for (const rewardItem of questReward.items) { - this.itemHelper.addUpdObjectToItem(rewardItem); - - // Reward items are granted Found in Raid status - rewardItem.upd.SpawnedInSession = true; - - // Is root item, fix stacks - if (rewardItem._id === questReward.target) { - // Is base reward item - if ( - rewardItem.parentId !== undefined && - rewardItem.parentId === "hideout" && // Has parentId of hideout - rewardItem.upd !== undefined && - rewardItem.upd.StackObjectsCount !== undefined && // Has upd with stackobject count - rewardItem.upd.StackObjectsCount > 1 // More than 1 item in stack - ) { - rewardItem.upd.StackObjectsCount = 1; - } - targets = this.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 - for (const target of targets) { - target._id = rewardItem._id; - } - } else { - // Is child mod - if (questReward.items[0].upd.SpawnedInSession) { - // Propigate FiR status into child items - rewardItem.upd.SpawnedInSession = questReward.items[0].upd.SpawnedInSession; - } - - mods.push(rewardItem); - } - } - - // Add mods to the base items, fix ids - for (const target of targets) { - // This has all the original id relations since we reset the id to the original after the splitStack - const itemsClone = [this.cloner.clone(target)]; - // Here we generate a new id for the root item - target._id = this.hashUtil.generate(); - - for (const mod of mods) { - itemsClone.push(this.cloner.clone(mod)); - } - - rewardItems = rewardItems.concat(this.itemHelper.reparentItemAndChildren(target, itemsClone)); - } - - return rewardItems; - } - - /** - * Add missing mod items to a quest armor reward - * @param originalRewardRootItem Original armor reward item from IReward.items object - * @param questReward Armor reward from quest - */ - protected generateArmorRewardChildSlots(originalRewardRootItem: IItem, questReward: IReward): void { - // Look for a default preset from globals for armor - const defaultPreset = this.presetHelper.getDefaultPreset(originalRewardRootItem._tpl); - if (defaultPreset) { - // Found preset, use mods to hydrate reward item - const presetAndMods: IItem[] = this.itemHelper.replaceIDs(defaultPreset._items); - const newRootId = this.itemHelper.remapRootItemId(presetAndMods); - - questReward.items = presetAndMods; - - // Find root item and set its stack count - const rootItem = questReward.items.find((item) => item._id === newRootId); - - // Remap target id to the new presets root id - questReward.target = rootItem._id; - - // Copy over stack count otherwise reward shows as missing in client - this.itemHelper.addUpdObjectToItem(rootItem); - - rootItem.upd.StackObjectsCount = originalRewardRootItem.upd.StackObjectsCount; - - return; - } - - this.logger.warning( - `Unable to find default preset for armor ${originalRewardRootItem._tpl}, adding mods manually`, - ); - const itemDbData = this.itemHelper.getItem(originalRewardRootItem._tpl)[1]; - - // Hydrate reward with only 'required' mods - necessary for things like helmets otherwise you end up with nvgs/visors etc - questReward.items = this.itemHelper.addChildSlotItems(questReward.items, itemDbData, undefined, true); + return clonedQuest; } } diff --git a/project/src/helpers/RewardHelper.ts b/project/src/helpers/RewardHelper.ts new file mode 100644 index 00000000..1c86161e --- /dev/null +++ b/project/src/helpers/RewardHelper.ts @@ -0,0 +1,375 @@ +import { ItemHelper } from "@spt/helpers/ItemHelper"; +import { PresetHelper } from "@spt/helpers/PresetHelper"; +import { ProfileHelper } from "@spt/helpers/ProfileHelper"; +import { TraderHelper } from "@spt/helpers/TraderHelper"; +import { IPmcData } from "@spt/models/eft/common/IPmcData"; +import { CustomisationSource } from "@spt/models/eft/common/tables/ICustomisationStorage"; +import { IItem } from "@spt/models/eft/common/tables/IItem"; +import { IReward } from "@spt/models/eft/common/tables/IReward"; +import { IHideoutProduction } from "@spt/models/eft/hideout/IHideoutProduction"; +import { IItemEventRouterResponse } from "@spt/models/eft/itemEvent/IItemEventRouterResponse"; +import { ISptProfile } from "@spt/models/eft/profile/ISptProfile"; +import { RewardType } from "@spt/models/enums/RewardType"; +import { SkillTypes } from "@spt/models/enums/SkillTypes"; +import type { ILogger } from "@spt/models/spt/utils/ILogger"; +import { DatabaseService } from "@spt/services/DatabaseService"; +import { LocalisationService } from "@spt/services/LocalisationService"; +import { HashUtil } from "@spt/utils/HashUtil"; +import { TimeUtil } from "@spt/utils/TimeUtil"; +import type { ICloner } from "@spt/utils/cloners/ICloner"; +import { inject, injectable } from "tsyringe"; + +@injectable() +export class RewardHelper { + constructor( + @inject("PrimaryLogger") protected logger: ILogger, + @inject("HashUtil") protected hashUtil: HashUtil, + @inject("TimeUtil") protected timeUtil: TimeUtil, + @inject("ItemHelper") protected itemHelper: ItemHelper, + @inject("DatabaseService") protected databaseService: DatabaseService, + @inject("ProfileHelper") protected profileHelper: ProfileHelper, + @inject("LocalisationService") protected localisationService: LocalisationService, + @inject("TraderHelper") protected traderHelper: TraderHelper, + @inject("PresetHelper") protected presetHelper: PresetHelper, + @inject("PrimaryCloner") protected cloner: ICloner, + ) {} + + /** + * 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 applyRewards( + rewards: IReward[], + source: CustomisationSource, + fullProfile: ISptProfile, + profileData: IPmcData, + questId: string, + questResponse?: IItemEventRouterResponse, + ): IItem[] { + const sessionId = fullProfile?.info?.id; + const pmcProfile = fullProfile?.characters.pmc; + if (!pmcProfile) { + this.logger.error(`Unable to get pmc profile for: ${sessionId}, no rewards given`); + return []; + } + + const gameVersion = pmcProfile.Info.GameVersion; + + for (const reward of rewards) { + // Handle reward availability for different game versions, notAvailableInGameEditions currently not used + if (!this.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 + this.profileHelper.addSkillPointsToPlayer( + profileData, + reward.target as SkillTypes, + Number(reward.value), + ); + break; + case RewardType.EXPERIENCE: + this.profileHelper.addExperienceToPmc(sessionId, Number.parseInt(reward.value)); // this must occur first as the output object needs to take the modified profile exp value + break; + case RewardType.TRADER_STANDING: + this.traderHelper.addStandingToTrader( + sessionId, + reward.target, + Number.parseFloat(reward.value), + ); + break; + case RewardType.TRADER_UNLOCK: + this.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.ASSORTMENT_UNLOCK: + // Handled by getAssort(), locked assorts are stripped out by `assortHelper.stripLockedLoyaltyAssort()` before being sent to player + break; + case RewardType.ACHIEVEMENT: + this.addAchievementToProfile(fullProfile, reward.target); + break; + case RewardType.STASH_ROWS: + this.profileHelper.addStashRowsBonusToProfile(sessionId, Number.parseInt(reward.value)); // Add specified stash rows from reward - requires client restart + break; + case RewardType.PRODUCTIONS_SCHEME: + this.findAndAddHideoutProductionIdToProfile(pmcProfile, reward, questId, sessionId, questResponse); + break; + case RewardType.POCKETS: + this.profileHelper.replaceProfilePocketTpl(pmcProfile, reward.target); + break; + case RewardType.CUSTOMIZATION_DIRECT: + this.profileHelper.addHideoutCustomisationUnlock(fullProfile, reward, source); + break; + default: + this.logger.error( + this.localisationService.getText("reward-type_not_handled", { + rewardType: reward.type, + questId: questId, + }), + ); + break; + } + } + + return this.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 rewardIsForGameEdition(reward: IReward, gameVersion: string): boolean { + if (reward.availableInGameEditions?.length > 0 && !reward.availableInGameEditions?.includes(gameVersion)) { + // Reward has edition whitelist and game version isnt in it + return false; + } + + if (reward.notAvailableInGameEditions?.length > 0 && reward.notAvailableInGameEditions?.includes(gameVersion)) { + // Reward has edition blacklist and game version is in it + return false; + } + + // No whitelist/blacklist or reward isnt 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 findAndAddHideoutProductionIdToProfile( + pmcData: IPmcData, + craftUnlockReward: IReward, + questId: string, + sessionID: string, + response?: IItemEventRouterResponse, + ): void { + const matchingProductions = this.getRewardProductionMatch(craftUnlockReward, questId); + if (matchingProductions.length !== 1) { + this.logger.error( + this.localisationService.getText("reward-unable_to_find_matching_hideout_production", { + questId: questId, + matchCount: matchingProductions.length, + }), + ); + + return; + } + + // Add above match to pmc profile + client response + const matchingCraftId = matchingProductions[0]._id; + pmcData.UnlockedInfo.unlockedProductionRecipe.push(matchingCraftId); + if (response) { + 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 getRewardProductionMatch(craftUnlockReward: IReward, questId: string): IHideoutProduction[] { + // Get hideout crafts and find those that match by areatype/required level/end product tpl - hope for just one match + const craftingRecipes = this.databaseService.getHideout().production.recipes; + + // Area that will be used to craft unlocked item + const desiredHideoutAreaType = Number.parseInt(craftUnlockReward.traderId); + + let matchingProductions = craftingRecipes.filter( + (prod) => + prod.areaType === desiredHideoutAreaType && + //prod.requirements.some((requirement) => requirement.questId === questId) && // BSG dont store the quest id in requirement any more! + prod.requirements.some((requirement) => requirement.type === "QuestComplete") && + prod.requirements.some((requirement) => requirement.requiredLevel === craftUnlockReward.loyaltyLevel) && + prod.endProduct === craftUnlockReward.items[0]._tpl, + ); + + // More/less than single match, above filtering wasn't strict enough + if (matchingProductions.length !== 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.filter((prod) => + prod.requirements.some((requirement) => requirement.questId === questId), + ); + } + + 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 getRewardItems(rewards: IReward[], gameVersion: string): IItem[] { + // Iterate over all rewards with the desired status, flatten out items that have a type of Item + const rewardItems = rewards.flatMap((reward: IReward) => + reward.type === "Item" && this.rewardIsForGameEdition(reward, gameVersion) + ? this.processReward(reward) + : [], + ); + + return rewardItems; + } + + /** + * Take reward item and set FiR status + fix stack sizes + fix mod Ids + * @param reward Reward item to fix + * @returns Fixed rewards + */ + protected processReward(reward: IReward): IItem[] { + /** item with mods to return */ + let rewardItems: IItem[] = []; + let targets: IItem[] = []; + const mods: IItem[] = []; + + // Is armor item that may need inserts / plates + if (reward.items.length === 1 && this.itemHelper.armorItemCanHoldMods(reward.items[0]._tpl)) { + // Only process items with slots + if (this.itemHelper.itemHasSlots(reward.items[0]._tpl)) { + // Attempt to pull default preset from globals and add child items to reward (clones reward.items) + this.generateArmorRewardChildSlots(reward.items[0], reward); + } + } + + for (const rewardItem of reward.items) { + this.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 !== undefined && + rewardItem.parentId === "hideout" && // Has parentId of hideout + rewardItem.upd !== undefined && + rewardItem.upd.StackObjectsCount !== undefined && // Has upd with stackobject count + rewardItem.upd.StackObjectsCount > 1 // More than 1 item in stack + ) { + rewardItem.upd.StackObjectsCount = 1; + } + targets = this.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 + for (const target of targets) { + target._id = rewardItem._id; + } + } else { + // Is child mod + if (reward.items[0].upd.SpawnedInSession) { + // Propigate FiR status into child items + rewardItem.upd.SpawnedInSession = reward.items[0].upd.SpawnedInSession; + } + + mods.push(rewardItem); + } + } + + // Add mods to the base items, fix ids + for (const target of targets) { + // This has all the original id relations since we reset the id to the original after the splitStack + const itemsClone = [this.cloner.clone(target)]; + // Here we generate a new id for the root item + target._id = this.hashUtil.generate(); + + for (const mod of mods) { + itemsClone.push(this.cloner.clone(mod)); + } + + rewardItems = rewardItems.concat(this.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 generateArmorRewardChildSlots(originalRewardRootItem: IItem, reward: IReward): void { + // Look for a default preset from globals for armor + const defaultPreset = this.presetHelper.getDefaultPreset(originalRewardRootItem._tpl); + if (defaultPreset) { + // Found preset, use mods to hydrate reward item + const presetAndMods: IItem[] = this.itemHelper.replaceIDs(defaultPreset._items); + const newRootId = this.itemHelper.remapRootItemId(presetAndMods); + + reward.items = presetAndMods; + + // Find root item and set its stack count + const rootItem = reward.items.find((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 + this.itemHelper.addUpdObjectToItem(rootItem); + + rootItem.upd.StackObjectsCount = originalRewardRootItem.upd.StackObjectsCount; + + return; + } + + this.logger.warning( + `Unable to find default preset for armor ${originalRewardRootItem._tpl}, adding mods manually`, + ); + const itemDbData = this.itemHelper.getItem(originalRewardRootItem._tpl)[1]; + + // Hydrate reward with only 'required' mods - necessary for things like helmets otherwise you end up with nvgs/visors etc + reward.items = this.itemHelper.addChildSlotItems(reward.items, itemDbData, undefined, 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 addAchievementToProfile(fullProfile: ISptProfile, achievementId: string): void { + // Add achievement id to profile with timestamp it was unlocked + fullProfile.characters.pmc.Achievements[achievementId] = this.timeUtil.getTimestamp(); + + // Check for any customisation unlocks + const achievementDataDb = this.databaseService + .getTemplates() + .achievements.find((achievement) => achievement.id === achievementId); + if (!achievementDataDb) { + 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 + const pmcProfile = fullProfile.characters.pmc; + this.applyRewards( + achievementDataDb.rewards, + CustomisationSource.ACHIEVEMENT, + fullProfile, + pmcProfile, + achievementDataDb.id, + ); + } +} diff --git a/project/src/services/LocationLifecycleService.ts b/project/src/services/LocationLifecycleService.ts index 49578916..03fef351 100644 --- a/project/src/services/LocationLifecycleService.ts +++ b/project/src/services/LocationLifecycleService.ts @@ -51,7 +51,8 @@ import { RandomUtil } from "@spt/utils/RandomUtil"; import { TimeUtil } from "@spt/utils/TimeUtil"; import type { ICloner } from "@spt/utils/cloners/ICloner"; import { inject, injectable } from "tsyringe"; -import { TransitionType } from "../models/enums/TransitionType"; +import { TransitionType } from "@spt/models/enums/TransitionType"; +import { RewardHelper } from "@spt/helpers/RewardHelper"; @injectable() export class LocationLifecycleService { @@ -73,6 +74,7 @@ export class LocationLifecycleService { @inject("InRaidHelper") protected inRaidHelper: InRaidHelper, @inject("HealthHelper") protected healthHelper: HealthHelper, @inject("QuestHelper") protected questHelper: QuestHelper, + @inject("RewardHelper") protected rewardHelper: RewardHelper, @inject("MatchBotDetailsCacheService") protected matchBotDetailsCacheService: MatchBotDetailsCacheService, @inject("PmcChatResponseService") protected pmcChatResponseService: PmcChatResponseService, @inject("PlayerScavGenerator") protected playerScavGenerator: PlayerScavGenerator, @@ -666,7 +668,7 @@ export class LocationLifecycleService { pmcProfile.SurvivorClass = postRaidProfile.SurvivorClass; // MUST occur prior to profile achievements being overwritten by post-raid achievements - this.processAchievementCustomisationRewards(fullProfile, postRaidProfile.Achievements); + this.processAchievementRewards(fullProfile, postRaidProfile.Achievements); pmcProfile.Achievements = postRaidProfile.Achievements; pmcProfile.Quests = this.processPostRaidQuests(postRaidProfile.Quests); @@ -730,14 +732,13 @@ export class LocationLifecycleService { } /** - * Check for and add any customisations found via the gained achievements this raid + * Check for and add any rewards found via the gained achievements this raid * @param fullProfile Profile to add customisations to - * @param postRaidAchievements Achievements gained this raid + * @param postRaidAchievements All profile achievements at the end of the raid */ - protected processAchievementCustomisationRewards( - fullProfile: ISptProfile, - postRaidAchievements: Record, - ): void { + protected processAchievementRewards(fullProfile: ISptProfile, postRaidAchievements: Record): void { + const sessionId = fullProfile.info.id; + const pmcProfile = fullProfile.characters.pmc; const preRaidAchievementIds = Object.keys(fullProfile.characters.pmc.Achievements); const postRaidAchievementIds = Object.keys(postRaidAchievements); const achievementIdsAcquiredThisRaid = postRaidAchievementIds.filter( @@ -756,14 +757,23 @@ export class LocationLifecycleService { return; } - // Get only customisation rewards from above achievements - const customisationRewards = achievements - .filter((achievement) => achievement?.rewards.some((reward) => reward.type === "CustomizationDirect")) - .flatMap((achievement) => achievement?.rewards); - - // Insert customisations into profile - for (const reward of customisationRewards) { - this.profileHelper.addHideoutCustomisationUnlock(fullProfile, reward, CustomisationSource.ACHIEVEMENT); + for (const achievement of achievements) { + const rewardItems = this.rewardHelper.applyRewards( + achievement.rewards, + CustomisationSource.ACHIEVEMENT, + fullProfile, + pmcProfile, + achievement.id, + ); + if (rewardItems?.length > 0) { + this.mailSendService.sendLocalisedSystemMessageToPlayer( + sessionId, + "670547bb5fa0b1a7c30d5836 0", + rewardItems, + [], + this.timeUtil.getHoursAsSeconds(24 * 7), + ); + } } } diff --git a/project/src/services/ProfileFixerService.ts b/project/src/services/ProfileFixerService.ts index ef7ba1e6..9ce28b3f 100644 --- a/project/src/services/ProfileFixerService.ts +++ b/project/src/services/ProfileFixerService.ts @@ -2,7 +2,7 @@ import { HideoutHelper } from "@spt/helpers/HideoutHelper"; import { InventoryHelper } from "@spt/helpers/InventoryHelper"; import { ItemHelper } from "@spt/helpers/ItemHelper"; import { ProfileHelper } from "@spt/helpers/ProfileHelper"; -import { QuestRewardHelper } from "@spt/helpers/QuestRewardHelper"; +import { RewardHelper } from "@spt/helpers/RewardHelper"; import { TraderHelper } from "@spt/helpers/TraderHelper"; import { IPmcData } from "@spt/models/eft/common/IPmcData"; import { IBonus, IHideoutSlot } from "@spt/models/eft/common/tables/IBotBase"; @@ -51,7 +51,7 @@ export class ProfileFixerService { @inject("HashUtil") protected hashUtil: HashUtil, @inject("ConfigServer") protected configServer: ConfigServer, @inject("PrimaryCloner") protected cloner: ICloner, - @inject("QuestRewardHelper") protected questRewardHelper: QuestRewardHelper, + @inject("RewardHelper") protected rewardHelper: RewardHelper, ) { this.coreConfig = this.configServer.getConfig(ConfigTypes.CORE); this.ragfairConfig = this.configServer.getConfig(ConfigTypes.RAGFAIR); @@ -365,9 +365,9 @@ export class ProfileFixerService { productionUnlockReward: IReward, questDetails: IQuest, ): void { - const matchingProductions = this.questRewardHelper.getRewardProductionMatch( + const matchingProductions = this.rewardHelper.getRewardProductionMatch( productionUnlockReward, - questDetails, + questDetails._id, ); if (matchingProductions.length !== 1) { this.logger.error(