import { inject, injectable } from "tsyringe"; import { DialogueHelper } from "@spt-aki/helpers/DialogueHelper"; import { ItemHelper } from "@spt-aki/helpers/ItemHelper"; import { PaymentHelper } from "@spt-aki/helpers/PaymentHelper"; import { PresetHelper } from "@spt-aki/helpers/PresetHelper"; import { ProfileHelper } from "@spt-aki/helpers/ProfileHelper"; import { QuestConditionHelper } from "@spt-aki/helpers/QuestConditionHelper"; import { RagfairServerHelper } from "@spt-aki/helpers/RagfairServerHelper"; import { TraderHelper } from "@spt-aki/helpers/TraderHelper"; import { IPmcData } from "@spt-aki/models/eft/common/IPmcData"; import { Common, IQuestStatus } from "@spt-aki/models/eft/common/tables/IBotBase"; import { Item } from "@spt-aki/models/eft/common/tables/IItem"; import { IQuest, IQuestCondition, IQuestReward } from "@spt-aki/models/eft/common/tables/IQuest"; import { IRepeatableQuest } from "@spt-aki/models/eft/common/tables/IRepeatableQuests"; import { IItemEventRouterResponse } from "@spt-aki/models/eft/itemEvent/IItemEventRouterResponse"; import { IAcceptQuestRequestData } from "@spt-aki/models/eft/quests/IAcceptQuestRequestData"; import { IFailQuestRequestData } from "@spt-aki/models/eft/quests/IFailQuestRequestData"; import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes"; import { MessageType } from "@spt-aki/models/enums/MessageType"; import { QuestRewardType } from "@spt-aki/models/enums/QuestRewardType"; import { QuestStatus } from "@spt-aki/models/enums/QuestStatus"; import { SkillTypes } from "@spt-aki/models/enums/SkillTypes"; import { IQuestConfig } from "@spt-aki/models/spt/config/IQuestConfig"; import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; import { EventOutputHolder } from "@spt-aki/routers/EventOutputHolder"; import { ConfigServer } from "@spt-aki/servers/ConfigServer"; import { DatabaseServer } from "@spt-aki/servers/DatabaseServer"; import { LocaleService } from "@spt-aki/services/LocaleService"; import { LocalisationService } from "@spt-aki/services/LocalisationService"; import { MailSendService } from "@spt-aki/services/MailSendService"; import { HashUtil } from "@spt-aki/utils/HashUtil"; import { JsonUtil } from "@spt-aki/utils/JsonUtil"; import { TimeUtil } from "@spt-aki/utils/TimeUtil"; @injectable() export class QuestHelper { protected questConfig: IQuestConfig; constructor( @inject("WinstonLogger") protected logger: ILogger, @inject("JsonUtil") protected jsonUtil: JsonUtil, @inject("TimeUtil") protected timeUtil: TimeUtil, @inject("HashUtil") protected hashUtil: HashUtil, @inject("ItemHelper") protected itemHelper: ItemHelper, @inject("QuestConditionHelper") protected questConditionHelper: QuestConditionHelper, @inject("EventOutputHolder") protected eventOutputHolder: EventOutputHolder, @inject("DatabaseServer") protected databaseServer: DatabaseServer, @inject("LocaleService") protected localeService: LocaleService, @inject("RagfairServerHelper") protected ragfairServerHelper: RagfairServerHelper, @inject("DialogueHelper") protected dialogueHelper: DialogueHelper, @inject("ProfileHelper") protected profileHelper: ProfileHelper, @inject("PaymentHelper") protected paymentHelper: PaymentHelper, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("TraderHelper") protected traderHelper: TraderHelper, @inject("PresetHelper") protected presetHelper: PresetHelper, @inject("MailSendService") protected mailSendService: MailSendService, @inject("ConfigServer") protected configServer: ConfigServer, ) { this.questConfig = this.configServer.getConfig(ConfigTypes.QUEST); } /** * Get status of a quest in player profile by its id * @param pmcData Profile to search * @param questId Quest id to look up * @returns QuestStatus enum */ public getQuestStatus(pmcData: IPmcData, questId: string): QuestStatus { const quest = pmcData.Quests?.find((q) => q.qid === questId); return quest ? quest.status : QuestStatus.Locked; } /** * returns true is the level condition is satisfied * @param playerLevel Players level * @param condition Quest condition * @returns true if player level is greater than or equal to quest */ public doesPlayerLevelFulfilCondition(playerLevel: number, condition: IQuestCondition): boolean { if (condition.conditionType === "Level") { switch (condition.compareMethod) { case ">=": return playerLevel >= condition.value; case ">": return playerLevel > condition.value; case "<": return playerLevel < condition.value; case "<=": return playerLevel <= condition.value; case "=": return playerLevel === condition.value; default: this.logger.error( this.localisationService.getText( "quest-unable_to_find_compare_condition", condition.compareMethod, ), ); return false; } } } /** * Get the quests found in both arrays (inner join) * @param before Array of quests #1 * @param after Array of quests #2 * @returns Reduction of cartesian product between two quest arrays */ public getDeltaQuests(before: IQuest[], after: IQuest[]): IQuest[] { const knownQuestsIds = []; for (const q of before) { knownQuestsIds.push(q._id); } if (knownQuestsIds.length) { return after.filter((q) => { return knownQuestsIds.indexOf(q._id) === -1; }); } return after; } /** * Adjust skill experience for low skill levels, mimicing the official client * @param profileSkill the skill experience is being added to * @param progressAmount the amount of experience being added to the skill * @returns the adjusted skill progress gain */ public adjustSkillExpForLowLevels(profileSkill: Common, progressAmount: number): number { let currentLevel = Math.floor(profileSkill.Progress / 100); // Only run this if the current level is under 9 if (currentLevel >= 9) { return progressAmount; } // This calculates how much progress we have in the skill's starting level let startingLevelProgress = (profileSkill.Progress % 100) * ((currentLevel + 1) / 10); // The code below assumes a 1/10th progress skill amount let remainingProgress = progressAmount / 10; // We have to do this loop to handle edge cases where the provided XP bumps your level up // See "CalculateExpOnFirstLevels" in client for original logic let adjustedSkillProgress = 0; while (remainingProgress > 0 && currentLevel < 9) { // Calculate how much progress to add, limiting it to the current level max progress const currentLevelRemainingProgress = ((currentLevel + 1) * 10) - startingLevelProgress; this.logger.debug(`currentLevelRemainingProgress: ${currentLevelRemainingProgress}`); const progressToAdd = Math.min(remainingProgress, currentLevelRemainingProgress); const adjustedProgressToAdd = (10 / (currentLevel + 1)) * progressToAdd; this.logger.debug(`Progress To Add: ${progressToAdd} Adjusted for level: ${adjustedProgressToAdd}`); // Add the progress amount adjusted by level adjustedSkillProgress += adjustedProgressToAdd; remainingProgress -= progressToAdd; startingLevelProgress = 0; currentLevel++; } // If there's any remaining progress, add it. This handles if you go from level 8 -> 9 if (remainingProgress > 0) { adjustedSkillProgress += remainingProgress; } return adjustedSkillProgress; } /** * Get quest name by quest id * @param questId id to get * @returns */ public getQuestNameFromLocale(questId: string): string { const questNameKey = `${questId} name`; return this.localeService.getLocaleDb()[questNameKey]; } /** * Check if trader has sufficient loyalty to fulfill quest requirement * @param questProperties Quest props * @param profile Player profile * @returns true if loyalty is high enough to fulfill quest requirement */ public traderLoyaltyLevelRequirementCheck(questProperties: IQuestCondition, profile: IPmcData): boolean { const requiredLoyaltyLevel = Number(questProperties.value); const trader = profile.TradersInfo[questProperties.target]; if (!trader) { this.logger.error(`Unable to find trader: ${questProperties.target} in profile`); } return this.compareAvailableForValues(trader.loyaltyLevel, requiredLoyaltyLevel, questProperties.compareMethod); } /** * Check if trader has sufficient standing to fulfill quest requirement * @param questProperties Quest props * @param profile Player profile * @returns true if standing is high enough to fulfill quest requirement */ public traderStandingRequirementCheck(questProperties: IQuestCondition, profile: IPmcData): boolean { const requiredStanding = Number(questProperties.value); const trader = profile.TradersInfo[questProperties.target]; if (!trader) { this.logger.error(`Unable to find trader: ${questProperties.target} in profile`); } return this.compareAvailableForValues(trader.standing, requiredStanding, questProperties.compareMethod); } protected compareAvailableForValues(current: number, required: number, compareMethod: string): boolean { switch (compareMethod) { case ">=": return current >= required; case ">": return current > required; case "<=": return current <= required; case "<": return current < required; case "!=": return current !== required; case "==": return current === required; default: this.logger.error(this.localisationService.getText("quest-compare_operator_unhandled", compareMethod)); return false; } } /** * 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: IQuestReward): Item[] { /** item with mods to return */ let rewardItems: Item[] = []; let targets: Item[] = []; const mods: Item[] = []; // 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 item of questReward.items) { if (!item.upd) { item.upd = {}; } // Reward items are granted Found in Raid status item.upd.SpawnedInSession = true; // Is root item, fix stacks if (item._id === questReward.target) { // Is base reward item if ( (item.parentId !== undefined) && (item.parentId === "hideout") // Has parentId of hideout && (item.upd !== undefined) && (item.upd.StackObjectsCount !== undefined) // Has upd with stackobject count && (item.upd.StackObjectsCount > 1) // More than 1 item in stack ) { item.upd.StackObjectsCount = 1; } targets = this.itemHelper.splitStack(item); // 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 = item._id; } } else { // Is child mod if (questReward.items[0].upd.SpawnedInSession) { // Propigate FiR status into child items item.upd.SpawnedInSession = questReward.items[0].upd.SpawnedInSession; } mods.push(item); } } // 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.jsonUtil.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.jsonUtil.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 IQuestReward.items object * @param questReward Armor reward from quest */ protected generateArmorRewardChildSlots(originalRewardRootItem: Item, questReward: IQuestReward): 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: Item[] = 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 if (!rootItem.upd) { rootItem.upd = {}; } 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, null, true); } /** * Gets a flat list of reward items for the given quest at a specific state (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 */ public getQuestRewardItems(quest: IQuest, status: QuestStatus): Item[] { // 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: IQuestReward) => reward.type === "Item" ? this.processReward(reward) : [] ); return questRewards; } /** * Look up quest in db by accepted quest id and construct a profile-ready object ready to store in profile * @param pmcData Player profile * @param newState State the new quest should be in when returned * @param acceptedQuest Details of accepted quest from client */ public getQuestReadyForProfile( pmcData: IPmcData, newState: QuestStatus, acceptedQuest: IAcceptQuestRequestData, ): IQuestStatus { const currentTimestamp = this.timeUtil.getTimestamp(); const existingQuest = pmcData.Quests.find((q) => q.qid === acceptedQuest.qid); if (existingQuest) { // Quest exists, update its status existingQuest.startTime = currentTimestamp; existingQuest.status = newState; existingQuest.statusTimers[newState] = currentTimestamp; existingQuest.completedConditions = []; if (existingQuest.availableAfter) { delete existingQuest.availableAfter; } return existingQuest; } // Quest doesn't exists, add it const newQuest: IQuestStatus = { qid: acceptedQuest.qid, startTime: currentTimestamp, status: newState, statusTimers: {}, }; // Check if quest has a prereq to be placed in a 'pending' state, otherwise set status timers value const questDbData = this.getQuestFromDb(acceptedQuest.qid, pmcData); if (!questDbData) { this.logger.error(`Quest: ${acceptedQuest.qid} of type: ${acceptedQuest.type} not found`); } const waitTime = questDbData?.conditions.AvailableForStart.find((x) => x.availableAfter > 0); if (waitTime && acceptedQuest.type !== "repeatable") { // Quest should be put into 'pending' state newQuest.startTime = 0; newQuest.status = QuestStatus.AvailableAfter; // 9 newQuest.availableAfter = currentTimestamp + waitTime.availableAfter; } else { newQuest.statusTimers[newState.toString()] = currentTimestamp; newQuest.completedConditions = []; } return newQuest; } /** * Get quests that can be shown to player after starting a quest * @param startedQuestId Quest started by player * @param sessionID Session id * @returns Quests accessible to player incuding newly unlocked quests now quest (startedQuestId) was started */ public getNewlyAccessibleQuestsWhenStartingQuest(startedQuestId: string, sessionID: string): IQuest[] { // Get quest acceptance data from profile const profile: IPmcData = this.profileHelper.getPmcProfile(sessionID); const startedQuestInProfile = profile.Quests.find((x) => x.qid === startedQuestId); // Get quests that const eligibleQuests = this.getQuestsFromDb().filter((quest) => { // Quest is accessible to player when the accepted quest passed into param is started // e.g. Quest A passed in, quest B is looped over and has requirement of A to be started, include it const acceptedQuestCondition = quest.conditions.AvailableForStart.find((x) => { return x.conditionType === "Quest" && x.target?.includes(startedQuestId) && x.status?.includes(QuestStatus.Started); }); // Not found, skip quest if (!acceptedQuestCondition) { return false; } const standingRequirements = this.questConditionHelper.getStandingConditions( quest.conditions.AvailableForStart, ); for (const condition of standingRequirements) { if (!this.traderStandingRequirementCheck(condition, profile)) { return false; } } const loyaltyRequirements = this.questConditionHelper.getLoyaltyConditions( quest.conditions.AvailableForStart, ); for (const condition of loyaltyRequirements) { if (!this.traderLoyaltyLevelRequirementCheck(condition, profile)) { return false; } } // Include if quest found in profile and is started or ready to hand in return startedQuestInProfile && ([QuestStatus.Started, QuestStatus.AvailableForFinish].includes(startedQuestInProfile.status)); }); return this.getQuestsWithOnlyLevelRequirementStartCondition(eligibleQuests); } /** * Get quests that can be shown to player after failing a quest * @param failedQuestId Id of the quest failed by player * @param sessionId Session id * @returns IQuest array */ public failedUnlocked(failedQuestId: string, sessionId: string): IQuest[] { const profile = this.profileHelper.getPmcProfile(sessionId); const profileQuest = profile.Quests.find((x) => x.qid === failedQuestId); const quests = this.getQuestsFromDb().filter((q) => { const acceptedQuestCondition = q.conditions.AvailableForStart.find((c) => { return c.conditionType === "Quest" && c.target.includes(failedQuestId) && c.status[0] === QuestStatus.Fail; }); if (!acceptedQuestCondition) { return false; } return profileQuest && (profileQuest.status === QuestStatus.Fail); }); if (quests.length === 0) { return quests; } return this.getQuestsWithOnlyLevelRequirementStartCondition(quests); } /** * Adjust quest money rewards by passed in multiplier * @param quest Quest to multiple money rewards * @param multiplier Value to adjust money rewards by * @param questStatus Status of quest to apply money boost to rewards of * @returns Updated quest */ public applyMoneyBoost(quest: IQuest, multiplier: number, questStatus: QuestStatus): IQuest { const rewards: IQuestReward[] = quest.rewards?.[QuestStatus[questStatus]] ?? []; for (const reward of rewards) { if (reward.type === "Item") { if (this.paymentHelper.isMoneyTpl(reward.items[0]._tpl)) { reward.items[0].upd.StackObjectsCount += Math.round( reward.items[0].upd.StackObjectsCount * multiplier / 100, ); } } } return quest; } /** * Sets the item stack to new value, or delete the item if value <= 0 * // TODO maybe merge this function and the one from customization * @param pmcData Profile * @param itemId id of item to adjust stack size of * @param newStackSize Stack size to adjust to * @param sessionID Session id * @param output ItemEvent router response */ public changeItemStack( pmcData: IPmcData, itemId: string, newStackSize: number, sessionID: string, output: IItemEventRouterResponse, ): void { const inventoryItemIndex = pmcData.Inventory.items.findIndex((item) => item._id === itemId); if (inventoryItemIndex < 0) { this.logger.error(this.localisationService.getText("quest-item_not_found_in_inventory", itemId)); return; } if (newStackSize > 0) { const item = pmcData.Inventory.items[inventoryItemIndex]; if (!item.upd) { item.upd = {}; } item.upd.StackObjectsCount = newStackSize; this.addItemStackSizeChangeIntoEventResponse(output, sessionID, item); } else { // this case is probably dead Code right now, since the only calling function // checks explicitly for Value > 0. output.profileChanges[sessionID].items.del.push({ _id: itemId }); pmcData.Inventory.items.splice(inventoryItemIndex, 1); } } /** * Add item stack change object into output route event response * @param output Response to add item change event into * @param sessionId Session id * @param item Item that was adjusted */ protected addItemStackSizeChangeIntoEventResponse( output: IItemEventRouterResponse, sessionId: string, item: Item, ): void { output.profileChanges[sessionId].items.change.push({ _id: item._id, _tpl: item._tpl, parentId: item.parentId, slotId: item.slotId, location: item.location, upd: { StackObjectsCount: item.upd.StackObjectsCount }, }); } /** * Get quests, strip all requirement conditions except level * @param quests quests to process * @returns quest array without conditions */ protected getQuestsWithOnlyLevelRequirementStartCondition(quests: IQuest[]): IQuest[] { for (const i in quests) { quests[i] = this.getQuestWithOnlyLevelRequirementStartCondition(quests[i]); } return quests; } /** * Remove all quest conditions except for level requirement * @param quest quest to clean * @returns reset IQuest object */ public getQuestWithOnlyLevelRequirementStartCondition(quest: IQuest): IQuest { quest = this.jsonUtil.clone(quest); quest.conditions.AvailableForStart = quest.conditions.AvailableForStart.filter((q) => q.conditionType === "Level" ); return quest; } /** * Fail a quest in a player profile * @param pmcData Player profile * @param failRequest Fail quest request data * @param sessionID Session id * @param output Client output * @returns Item event router response */ public failQuest( pmcData: IPmcData, failRequest: IFailQuestRequestData, sessionID: string, output: IItemEventRouterResponse = null, ): IItemEventRouterResponse { // Prepare response to send back client if (!output) { output = this.eventOutputHolder.getOutput(sessionID); } this.updateQuestState(pmcData, QuestStatus.Fail, failRequest.qid); const questRewards = this.applyQuestReward(pmcData, failRequest.qid, QuestStatus.Fail, sessionID, output); // Create a dialog message for completing the quest. const quest = this.getQuestFromDb(failRequest.qid, pmcData); const matchingRepeatable = pmcData.RepeatableQuests.flatMap((repeatableType) => repeatableType.activeQuests) .find((activeQuest) => activeQuest._id === failRequest.qid); if (!(matchingRepeatable || quest)) { this.mailSendService.sendLocalisedNpcMessageToPlayer( sessionID, this.traderHelper.getTraderById(quest?.traderId ?? matchingRepeatable.traderId), // can be null when repeatable quest has been moved to inactiveQuests MessageType.QUEST_FAIL, quest.failMessageText, questRewards, this.timeUtil.getHoursAsSeconds(this.questConfig.redeemTime), ); } output.profileChanges[sessionID].quests.push(...this.failedUnlocked(failRequest.qid, sessionID)); return output; } /** * Get List of All Quests from db * NOT CLONED * @returns Array of IQuest objects */ public getQuestsFromDb(): IQuest[] { return Object.values(this.databaseServer.getTables().templates.quests); } /** * Get quest by id from database (repeatables are stored in profile, check there if questId not found) * @param questId Id of quest to find * @param pmcData Player profile * @returns IQuest object */ public getQuestFromDb(questId: string, pmcData: IPmcData): IQuest { let quest = this.databaseServer.getTables().templates.quests[questId]; // May be a repeatable quest if (!quest) { // Check daily/weekly objects for (const repeatableType of pmcData.RepeatableQuests) { quest = repeatableType.activeQuests.find((x) => x._id === questId); if (quest) { break; } } } return quest; } /** * Get a quests startedMessageText key from db, if no startedMessageText key found, use description key instead * @param startedMessageTextId startedMessageText property from IQuest * @param questDescriptionId description property from IQuest * @returns message id */ public getMessageIdForQuestStart(startedMessageTextId: string, questDescriptionId: string): string { // blank or is a guid, use description instead const startedMessageText = this.getQuestLocaleIdFromDb(startedMessageTextId); if ( !startedMessageText || startedMessageText.trim() === "" || startedMessageText.toLowerCase() === "test" || startedMessageText.length === 24 ) { return questDescriptionId; } return startedMessageTextId; } /** * Get the locale Id from locale db for a quest message * @param questMessageId Quest message id to look up * @returns Locale Id from locale db */ public getQuestLocaleIdFromDb(questMessageId: string): string { const locale = this.localeService.getLocaleDb(); return locale[questMessageId]; } /** * Alter a quests state + Add a record to its status timers object * @param pmcData Profile to update * @param newQuestState New state the quest should be in * @param questId Id of the quest to alter the status of */ public updateQuestState(pmcData: IPmcData, newQuestState: QuestStatus, questId: string): void { // Find quest in profile, update status to desired status const questToUpdate = pmcData.Quests.find((quest) => quest.qid === questId); if (questToUpdate) { questToUpdate.status = newQuestState; questToUpdate.statusTimers[newQuestState] = this.timeUtil.getTimestamp(); } } /** * Resets a quests values back to its chosen state * @param pmcData Profile to update * @param newQuestState New state the quest should be in * @param questId Id of the quest to alter the status of */ public resetQuestState(pmcData: IPmcData, newQuestState: QuestStatus, questId: string): void { const questToUpdate = pmcData.Quests.find((quest) => quest.qid === questId); if (questToUpdate) { const currentTimestamp = this.timeUtil.getTimestamp(); questToUpdate.status = newQuestState; // Only set start time when quest is being started if (newQuestState === QuestStatus.Started) { questToUpdate.startTime = currentTimestamp; } questToUpdate.statusTimers[newQuestState] = currentTimestamp; // Delete all status timers after applying new status for (const statusKey in questToUpdate.statusTimers) { if (Number.parseInt(statusKey) > newQuestState) { delete questToUpdate.statusTimers[statusKey]; } } // Remove all completed conditions questToUpdate.completedConditions = []; } } /** * Give player quest rewards - Skills/exp/trader standing/items/assort unlocks - Returns reward items player earned * @param profileData Player profile (scav or pmc) * @param questId questId of quest to get rewards for * @param state State of the quest to get rewards for * @param sessionId Session id * @param questResponse Response to send back to client * @returns Array of reward objects */ public applyQuestReward( profileData: IPmcData, questId: string, state: QuestStatus, sessionId: string, questResponse: IItemEventRouterResponse, ): Item[] { // Repeatable quest base data is always in PMCProfile, `profileData` may be scav profile // TODO: consider moving repeatable quest data to profile-agnostic location const pmcProfile = this.profileHelper.getPmcProfile(sessionId); let questDetails = this.getQuestFromDb(questId, pmcProfile); if (!questDetails) { this.logger.warning(`Unable to find quest: ${questId} from db, unable to give quest rewards`); return []; } // Check for and apply intel center money bonus if it exists const questMoneyRewardBonus = this.getQuestMoneyRewardBonus(pmcProfile); if (questMoneyRewardBonus > 0) { // Apply additional bonus from hideout skill questDetails = this.applyMoneyBoost(questDetails, questMoneyRewardBonus, state); // money = money + (money * intelCenterBonus / 100) } // e.g. 'Success' or 'AvailableForFinish' const questStateAsString = QuestStatus[state]; for (const reward of questDetails.rewards[questStateAsString]) { switch (reward.type) { case QuestRewardType.SKILL: this.profileHelper.addSkillPointsToPlayer( profileData, reward.target as SkillTypes, Number(reward.value), ); break; case QuestRewardType.EXPERIENCE: this.profileHelper.addExperienceToPmc(sessionId, parseInt(reward.value)); // this must occur first as the output object needs to take the modified profile exp value break; case QuestRewardType.TRADER_STANDING: this.traderHelper.addStandingToTrader(sessionId, reward.target, parseFloat(reward.value)); break; case QuestRewardType.TRADER_UNLOCK: this.traderHelper.setTraderUnlockedState(reward.target, true, sessionId); break; case QuestRewardType.ITEM: // Handled by getQuestRewardItems() below break; case QuestRewardType.ASSORTMENT_UNLOCK: // Handled elsewhere, TODO: find and say here break; case QuestRewardType.STASH_ROWS: this.logger.debug("Not implemented stash rows reward yet"); break; case QuestRewardType.PRODUCTIONS_SCHEME: this.findAndAddHideoutProductionIdToProfile( pmcProfile, reward, questDetails, sessionId, questResponse, ); 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); } /** * 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: IQuestReward, questDetails: IQuest, sessionID: string, response: IItemEventRouterResponse, ): void { // Get hideout crafts and find those that match by areatype/required level/end product tpl - hope for just one match const hideoutProductions = this.databaseServer.getTables().hideout.production; const matchingProductions = hideoutProductions.filter((x) => x.areaType === Number.parseInt(craftUnlockReward.traderId) && x.requirements.some((x) => x.requiredLevel === craftUnlockReward.loyaltyLevel) && x.endProduct === craftUnlockReward.items[0]._tpl ); // More/less than 1 match, above filtering wasn't strict enough 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; } /** * Get players money reward bonus from profile * @param pmcData player profile * @returns bonus as a percent */ protected getQuestMoneyRewardBonus(pmcData: IPmcData): number { // Check player has intel center const moneyRewardBonuses = pmcData.Bonuses.filter((x) => x.type === "QuestMoneyReward"); if (!moneyRewardBonuses) { return 0; } // Get a total of the quest money rewards let moneyRewardBonus = moneyRewardBonuses.reduce((acc, cur) => acc + cur.value, 0); // Apply hideout management bonus to money reward (up to 51% bonus) const hideoutManagementSkill = this.profileHelper.getSkillFromProfile(pmcData, SkillTypes.HIDEOUT_MANAGEMENT); if (hideoutManagementSkill) { moneyRewardBonus *= 1 + (hideoutManagementSkill.Progress / 10000); // 5100 becomes 0.51, add 1 to it, 1.51, multiply the moneyreward bonus by it (e.g. 15 x 51) } return moneyRewardBonus; } /** * Find quest with 'findItem' condition that needs the item tpl be handed in * @param itemTpl item tpl to look for * @param questIds Quests to search through for the findItem condition * @returns quest id with 'FindItem' condition id */ public getFindItemConditionByQuestItem( itemTpl: string, questIds: string[], allQuests: IQuest[], ): Record { const result: Record = {}; for (const questId of questIds) { const questInDb = allQuests.find((x) => x._id === questId); if (!questInDb) { this.logger.debug(`Unable to find quest: ${questId} in db, cannot get 'FindItem' condition, skipping`); continue; } const condition = questInDb.conditions.AvailableForFinish.find((c) => c.conditionType === "FindItem" && c?.target?.includes(itemTpl) ); if (condition) { result[questId] = condition.id; break; } } return result; } /** * Add all quests to a profile with the provided statuses * @param pmcProfile profile to update * @param statuses statuses quests should have */ public addAllQuestsToProfile(pmcProfile: IPmcData, statuses: QuestStatus[]): void { // Iterate over all quests in db const quests = this.databaseServer.getTables().templates.quests; for (const questIdKey in quests) { // Quest from db matches quests in profile, skip const questData = quests[questIdKey]; if (pmcProfile.Quests.find((x) => x.qid === questData._id)) { continue; } const statusesDict = {}; for (const status of statuses) { statusesDict[status] = this.timeUtil.getTimestamp(); } const questRecordToAdd: IQuestStatus = { qid: questIdKey, startTime: this.timeUtil.getTimestamp(), status: statuses[statuses.length - 1], statusTimers: statusesDict, completedConditions: [], availableAfter: 0, }; if (pmcProfile.Quests.some((x) => x.qid === questIdKey)) { // Update existing const existingQuest = pmcProfile.Quests.find((x) => x.qid === questIdKey); existingQuest.status = questRecordToAdd.status; existingQuest.statusTimers = questRecordToAdd.statusTimers; } else { // Add new pmcProfile.Quests.push(questRecordToAdd); } } } public findAndRemoveQuestFromArrayIfExists(questId: string, quests: IQuestStatus[]): void { const pmcQuestToReplaceStatus = quests.find((quest) => quest.qid === questId); if (pmcQuestToReplaceStatus) { quests.splice(quests.indexOf(pmcQuestToReplaceStatus), 1); } } /** * Return a list of quests that would fail when supplied quest is completed * @param completedQuestId quest completed id * @returns array of IQuest objects */ public getQuestsFailedByCompletingQuest(completedQuestId: string): IQuest[] { const questsInDb = this.getQuestsFromDb(); return questsInDb.filter((quest) => { // No fail conditions, exit early if (!quest.conditions.Fail || quest.conditions.Fail.length === 0) { return false; } return quest.conditions.Fail.some((condition) => condition.target?.includes(completedQuestId)); }); } }