diff --git a/project/src/controllers/QuestController.ts b/project/src/controllers/QuestController.ts index 4b5b7879..328e804f 100644 --- a/project/src/controllers/QuestController.ts +++ b/project/src/controllers/QuestController.ts @@ -5,7 +5,6 @@ import { QuestConditionHelper } from "@spt/helpers/QuestConditionHelper"; import { QuestHelper } from "@spt/helpers/QuestHelper"; import { TraderHelper } from "@spt/helpers/TraderHelper"; import { IPmcData } from "@spt/models/eft/common/IPmcData"; -import { IQuestStatus } from "@spt/models/eft/common/tables/IBotBase"; import { IItem } from "@spt/models/eft/common/tables/IItem"; import { IQuest, IQuestCondition } from "@spt/models/eft/common/tables/IQuest"; import { IPmcDataRepeatableQuest, IRepeatableQuest } from "@spt/models/eft/common/tables/IRepeatableQuests"; @@ -65,151 +64,7 @@ export class QuestController { * @returns array of IQuest */ public getClientQuests(sessionID: string): IQuest[] { - const questsToShowPlayer: IQuest[] = []; - const allQuests = this.questHelper.getQuestsFromDb(); - const profile: IPmcData = this.profileHelper.getPmcProfile(sessionID); - - for (const quest of allQuests) { - // Player already accepted the quest, show it regardless of status - const questInProfile = profile.Quests.find((x) => x.qid === quest._id); - if (questInProfile) { - quest.sptStatus = questInProfile.status; - questsToShowPlayer.push(quest); - continue; - } - - // Filter out bear quests for usec and vice versa - if (this.questHelper.questIsForOtherSide(profile.Info.Side, quest._id)) { - continue; - } - - if (!this.questHelper.showEventQuestToPlayer(quest._id)) { - continue; - } - - // Don't add quests that have a level higher than the user's - if (!this.playerLevelFulfillsQuestRequirement(quest, profile.Info.Level)) { - continue; - } - - // Player can use trader mods then remove them, leaving quests behind - const trader = profile.TradersInfo[quest.traderId]; - if (!trader) { - this.logger.debug( - `Unable to show quest: ${quest.QuestName} as its for a trader: ${quest.traderId} that no longer exists.`, - ); - - continue; - } - - const questRequirements = this.questConditionHelper.getQuestConditions(quest.conditions.AvailableForStart); - const loyaltyRequirements = this.questConditionHelper.getLoyaltyConditions( - quest.conditions.AvailableForStart, - ); - const standingRequirements = this.questConditionHelper.getStandingConditions( - quest.conditions.AvailableForStart, - ); - - // Quest has no conditions, standing or loyalty conditions, add to visible quest list - if ( - questRequirements.length === 0 && - loyaltyRequirements.length === 0 && - standingRequirements.length === 0 - ) { - quest.sptStatus = QuestStatus.AvailableForStart; - questsToShowPlayer.push(quest); - continue; - } - - // Check the status of each quest condition, if any are not completed - // then this quest should not be visible - let haveCompletedPreviousQuest = true; - for (const conditionToFulfil of questRequirements) { - // If the previous quest isn't in the user profile, it hasn't been completed or started - const prerequisiteQuest = profile.Quests.find((profileQuest) => - conditionToFulfil.target.includes(profileQuest.qid), - ); - if (!prerequisiteQuest) { - haveCompletedPreviousQuest = false; - break; - } - - // Prereq does not have its status requirement fulfilled - // Some bsg status ids are strings, MUST convert to number before doing includes check - if (!conditionToFulfil.status.map((status) => Number(status)).includes(prerequisiteQuest.status)) { - haveCompletedPreviousQuest = false; - break; - } - - // Has a wait timer - if (conditionToFulfil.availableAfter > 0) { - // Compare current time to unlock time for previous quest - const previousQuestCompleteTime = prerequisiteQuest.statusTimers[prerequisiteQuest.status]; - const unlockTime = previousQuestCompleteTime + conditionToFulfil.availableAfter; - if (unlockTime > this.timeUtil.getTimestamp()) { - this.logger.debug( - `Quest ${quest.QuestName} is locked for another ${ - unlockTime - this.timeUtil.getTimestamp() - } seconds`, - ); - } - } - } - - // Previous quest not completed, skip - if (!haveCompletedPreviousQuest) { - continue; - } - - let passesLoyaltyRequirements = true; - for (const condition of loyaltyRequirements) { - if (!this.questHelper.traderLoyaltyLevelRequirementCheck(condition, profile)) { - passesLoyaltyRequirements = false; - break; - } - } - - let passesStandingRequirements = true; - for (const condition of standingRequirements) { - if (!this.questHelper.traderStandingRequirementCheck(condition, profile)) { - passesStandingRequirements = false; - break; - } - } - - if (haveCompletedPreviousQuest && passesLoyaltyRequirements && passesStandingRequirements) { - quest.sptStatus = QuestStatus.AvailableForStart; - questsToShowPlayer.push(quest); - } - } - - return questsToShowPlayer; - } - - /** - * Does a provided quest have a level requirement equal to or below defined level - * @param quest Quest to check - * @param playerLevel level of player to test against quest - * @returns true if quest can be seen/accepted by player of defined level - */ - protected playerLevelFulfillsQuestRequirement(quest: IQuest, playerLevel: number): boolean { - if (!quest.conditions) { - // No conditions - return true; - } - - const levelConditions = this.questConditionHelper.getLevelConditions(quest.conditions.AvailableForStart); - if (levelConditions.length) { - for (const levelCondition of levelConditions) { - if (!this.questHelper.doesPlayerLevelFulfilCondition(playerLevel, levelCondition)) { - // Not valid, exit out - return false; - } - } - } - - // All conditions passed / has no level requirement, valid - return true; + return this.questHelper.getClientQuests(sessionID); } /** @@ -444,254 +299,7 @@ export class QuestController { body: ICompleteQuestRequestData, sessionID: string, ): IItemEventRouterResponse { - const completeQuestResponse = this.eventOutputHolder.getOutput(sessionID); - - const completedQuest = this.questHelper.getQuestFromDb(body.qid, pmcData); - const preCompleteProfileQuests = this.cloner.clone(pmcData.Quests); - - const completedQuestId = body.qid; - const clientQuestsClone = this.cloner.clone(this.getClientQuests(sessionID)); // Must be gathered prior to applyQuestReward() & failQuests() - - const newQuestState = QuestStatus.Success; - this.questHelper.updateQuestState(pmcData, newQuestState, completedQuestId); - const questRewards = this.questHelper.applyQuestReward( - pmcData, - body.qid, - newQuestState, - sessionID, - completeQuestResponse, - ); - - // Check for linked failed + unrestartable quests (only get quests not already failed - const questsToFail = this.getQuestsFailedByCompletingQuest(completedQuestId, pmcData); - if (questsToFail?.length > 0) { - this.failQuests(sessionID, pmcData, questsToFail, completeQuestResponse); - } - - // Show modal on player screen - this.sendSuccessDialogMessageOnQuestComplete(sessionID, pmcData, completedQuestId, questRewards); - - // Add diff of quests before completion vs after for client response - const questDelta = this.questHelper.getDeltaQuests(clientQuestsClone, this.getClientQuests(sessionID)); - - // Check newly available + failed quests for timegates and add them to profile - this.addTimeLockedQuestsToProfile(pmcData, [...questDelta], body.qid); - - // Inform client of quest changes - completeQuestResponse.profileChanges[sessionID].quests.push(...questDelta); - - // Check if it's a repeatable quest. If so, remove from Quests - for (const currentRepeatable of pmcData.RepeatableQuests) { - const repeatableQuest = currentRepeatable.activeQuests.find( - (activeRepeatable) => activeRepeatable._id === completedQuestId, - ); - if (repeatableQuest) { - // Need to remove redundant scav quest object as its no longer necessary, is tracked in pmc profile - if (repeatableQuest.side === "Scav") { - this.removeQuestFromScavProfile(sessionID, repeatableQuest._id); - } - } - } - - // Hydrate client response questsStatus array with data - const questStatusChanges = this.getQuestsWithDifferentStatuses(preCompleteProfileQuests, pmcData.Quests); - if (questStatusChanges) { - completeQuestResponse.profileChanges[sessionID].questsStatus.push(...questStatusChanges); - } - - // Recalculate level in event player leveled up - pmcData.Info.Level = this.playerService.calculateLevel(pmcData); - - return completeQuestResponse; - } - - /** - * Return a list of quests that would fail when supplied quest is completed - * @param completedQuestId quest completed id - * @returns array of IQuest objects - */ - protected getQuestsFailedByCompletingQuest(completedQuestId: string, pmcProfile: IPmcData): IQuest[] { - const questsInDb = this.questHelper.getQuestsFromDb(); - return questsInDb.filter((quest) => { - // No fail conditions, skip - if (!quest.conditions.Fail || quest.conditions.Fail.length === 0) { - return false; - } - - // Quest already failed in profile, skip - if ( - pmcProfile.Quests.some( - (profileQuest) => profileQuest.qid === quest._id && profileQuest.status === QuestStatus.Fail, - ) - ) { - return false; - } - - return quest.conditions.Fail.some((condition) => condition.target?.includes(completedQuestId)); - }); - } - - /** - * Remove a quest entirely from a profile - * @param sessionId Player id - * @param questIdToRemove Qid of quest to remove - */ - protected removeQuestFromScavProfile(sessionId: string, questIdToRemove: string): void { - const fullProfile = this.profileHelper.getFullProfile(sessionId); - const repeatableInScavProfile = fullProfile.characters.scav.Quests?.find((x) => x.qid === questIdToRemove); - if (!repeatableInScavProfile) { - this.logger.warning( - this.localisationService.getText("quest-unable_to_remove_scav_quest_from_profile", { - scavQuestId: questIdToRemove, - profileId: sessionId, - }), - ); - - return; - } - - fullProfile.characters.scav.Quests.splice( - fullProfile.characters.scav.Quests.indexOf(repeatableInScavProfile), - 1, - ); - } - - /** - * Return quests that have different statuses - * @param preQuestStatusus Quests before - * @param postQuestStatuses Quests after - * @returns QuestStatusChange array - */ - protected getQuestsWithDifferentStatuses( - preQuestStatusus: IQuestStatus[], - postQuestStatuses: IQuestStatus[], - ): IQuestStatus[] | undefined { - const result: IQuestStatus[] = []; - - for (const quest of postQuestStatuses) { - // Add quest if status differs or quest not found - const preQuest = preQuestStatusus.find((x) => x.qid === quest.qid); - if (!preQuest || preQuest.status !== quest.status) { - result.push(quest); - } - } - - if (result.length === 0) { - return undefined; - } - - return result; - } - - /** - * Send a popup to player on successful completion of a quest - * @param sessionID session id - * @param pmcData Player profile - * @param completedQuestId Completed quest id - * @param questRewards Rewards given to player - */ - protected sendSuccessDialogMessageOnQuestComplete( - sessionID: string, - pmcData: IPmcData, - completedQuestId: string, - questRewards: IItem[], - ): void { - const quest = this.questHelper.getQuestFromDb(completedQuestId, pmcData); - - this.mailSendService.sendLocalisedNpcMessageToPlayer( - sessionID, - this.traderHelper.getTraderById(quest.traderId), - MessageType.QUEST_SUCCESS, - quest.successMessageText, - questRewards, - this.timeUtil.getHoursAsSeconds(this.questHelper.getMailItemRedeemTimeHoursForProfile(pmcData)), - ); - } - - /** - * Look for newly available quests after completing a quest with a requirement to wait x minutes (time-locked) before being available and add data to profile - * @param pmcData Player profile to update - * @param quests Quests to look for wait conditions in - * @param completedQuestId Quest just completed - */ - protected addTimeLockedQuestsToProfile(pmcData: IPmcData, quests: IQuest[], completedQuestId: string): void { - // Iterate over quests, look for quests with right criteria - for (const quest of quests) { - // If quest has prereq of completed quest + availableAfter value > 0 (quest has wait time) - const nextQuestWaitCondition = quest.conditions.AvailableForStart.find( - (x) => x.target?.includes(completedQuestId) && x.availableAfter > 0, - ); - if (nextQuestWaitCondition) { - // Now + wait time - const availableAfterTimestamp = this.timeUtil.getTimestamp() + nextQuestWaitCondition.availableAfter; - - // Update quest in profile with status of AvailableAfter - const existingQuestInProfile = pmcData.Quests.find((x) => x.qid === quest._id); - if (existingQuestInProfile) { - existingQuestInProfile.availableAfter = availableAfterTimestamp; - existingQuestInProfile.status = QuestStatus.AvailableAfter; - existingQuestInProfile.startTime = 0; - existingQuestInProfile.statusTimers = {}; - - continue; - } - - pmcData.Quests.push({ - qid: quest._id, - startTime: 0, - status: QuestStatus.AvailableAfter, - statusTimers: { - 9: this.timeUtil.getTimestamp(), - }, - availableAfter: availableAfterTimestamp, - }); - } - } - } - - /** - * Fail the provided quests - * Update quest in profile, otherwise add fresh quest object with failed status - * @param sessionID session id - * @param pmcData player profile - * @param questsToFail quests to fail - * @param output Client output - */ - protected failQuests( - sessionID: string, - pmcData: IPmcData, - questsToFail: IQuest[], - output: IItemEventRouterResponse, - ): void { - for (const questToFail of questsToFail) { - // Skip failing a quest that has a fail status of something other than success - if (questToFail.conditions.Fail?.some((x) => x.status?.some((status) => status !== QuestStatus.Success))) { - continue; - } - - const isActiveQuestInPlayerProfile = pmcData.Quests.find((quest) => quest.qid === questToFail._id); - if (isActiveQuestInPlayerProfile) { - if (isActiveQuestInPlayerProfile.status !== QuestStatus.Fail) { - const failBody: IFailQuestRequestData = { - Action: "QuestFail", - qid: questToFail._id, - removeExcessItems: true, - }; - this.questHelper.failQuest(pmcData, failBody, sessionID, output); - } - } else { - // Failing an entirely new quest that doesnt exist in profile - const statusTimers = {}; - statusTimers[QuestStatus.Fail] = this.timeUtil.getTimestamp(); - const questData: IQuestStatus = { - qid: questToFail._id, - startTime: this.timeUtil.getTimestamp(), - statusTimers: statusTimers, - status: QuestStatus.Fail, - }; - pmcData.Quests.push(questData); - } - } + return this.questHelper.completeQuest(pmcData, body, sessionID); } /** diff --git a/project/src/helpers/QuestHelper.ts b/project/src/helpers/QuestHelper.ts index fe996749..fdac5ef6 100644 --- a/project/src/helpers/QuestHelper.ts +++ b/project/src/helpers/QuestHelper.ts @@ -12,6 +12,7 @@ import { IItem } from "@spt/models/eft/common/tables/IItem"; import { IQuest, IQuestCondition, IQuestReward } from "@spt/models/eft/common/tables/IQuest"; import { IItemEventRouterResponse } from "@spt/models/eft/itemEvent/IItemEventRouterResponse"; import { IAcceptQuestRequestData } from "@spt/models/eft/quests/IAcceptQuestRequestData"; +import { ICompleteQuestRequestData } from "@spt/models/eft/quests/ICompleteQuestRequestData"; import { IFailQuestRequestData } from "@spt/models/eft/quests/IFailQuestRequestData"; import { ConfigTypes } from "@spt/models/enums/ConfigTypes"; import { MessageType } from "@spt/models/enums/MessageType"; @@ -27,6 +28,7 @@ import { DatabaseService } from "@spt/services/DatabaseService"; import { LocaleService } from "@spt/services/LocaleService"; import { LocalisationService } from "@spt/services/LocalisationService"; import { MailSendService } from "@spt/services/MailSendService"; +import { PlayerService } from "@spt/services/PlayerService"; import { SeasonalEventService } from "@spt/services/SeasonalEventService"; import { HashUtil } from "@spt/utils/HashUtil"; import { TimeUtil } from "@spt/utils/TimeUtil"; @@ -55,6 +57,7 @@ export class QuestHelper { @inject("TraderHelper") protected traderHelper: TraderHelper, @inject("PresetHelper") protected presetHelper: PresetHelper, @inject("MailSendService") protected mailSendService: MailSendService, + @inject("PlayerService") protected playerService: PlayerService, @inject("ConfigServer") protected configServer: ConfigServer, @inject("PrimaryCloner") protected cloner: ICloner, ) { @@ -1166,4 +1169,408 @@ export class QuestHelper { return value; } + + public completeQuest( + pmcData: IPmcData, + body: ICompleteQuestRequestData, + sessionID: string, + ): IItemEventRouterResponse { + const completeQuestResponse = this.eventOutputHolder.getOutput(sessionID); + + const completedQuest = this.getQuestFromDb(body.qid, pmcData); + const preCompleteProfileQuests = this.cloner.clone(pmcData.Quests); + + const completedQuestId = body.qid; + const clientQuestsClone = this.cloner.clone(this.getClientQuests(sessionID)); // Must be gathered prior to applyQuestReward() & failQuests() + + const newQuestState = QuestStatus.Success; + this.updateQuestState(pmcData, newQuestState, completedQuestId); + const questRewards = this.applyQuestReward(pmcData, body.qid, newQuestState, sessionID, completeQuestResponse); + + // Check for linked failed + unrestartable quests (only get quests not already failed + const questsToFail = this.getQuestsFromProfileFailedByCompletingQuest(completedQuestId, pmcData); + if (questsToFail?.length > 0) { + this.failQuests(sessionID, pmcData, questsToFail, completeQuestResponse); + } + + // Show modal on player screen + this.sendSuccessDialogMessageOnQuestComplete(sessionID, pmcData, completedQuestId, questRewards); + + // Add diff of quests before completion vs after for client response + const questDelta = this.getDeltaQuests(clientQuestsClone, this.getClientQuests(sessionID)); + + // Check newly available + failed quests for timegates and add them to profile + this.addTimeLockedQuestsToProfile(pmcData, [...questDelta], body.qid); + + // Inform client of quest changes + completeQuestResponse.profileChanges[sessionID].quests.push(...questDelta); + + // Check if it's a repeatable quest. If so, remove from Quests + for (const currentRepeatable of pmcData.RepeatableQuests) { + const repeatableQuest = currentRepeatable.activeQuests.find( + (activeRepeatable) => activeRepeatable._id === completedQuestId, + ); + if (repeatableQuest) { + // Need to remove redundant scav quest object as its no longer necessary, is tracked in pmc profile + if (repeatableQuest.side === "Scav") { + this.removeQuestFromScavProfile(sessionID, repeatableQuest._id); + } + } + } + + // Hydrate client response questsStatus array with data + const questStatusChanges = this.getQuestsWithDifferentStatuses(preCompleteProfileQuests, pmcData.Quests); + if (questStatusChanges) { + completeQuestResponse.profileChanges[sessionID].questsStatus.push(...questStatusChanges); + } + + // Recalculate level in event player leveled up + pmcData.Info.Level = this.playerService.calculateLevel(pmcData); + + return completeQuestResponse; + } + + /** + * Handle client/quest/list + * Get all quests visible to player + * Exclude quests with incomplete preconditions (level/loyalty) + * @param sessionID session id + * @returns array of IQuest + */ + public getClientQuests(sessionID: string): IQuest[] { + const questsToShowPlayer: IQuest[] = []; + const allQuests = this.getQuestsFromDb(); + const profile: IPmcData = this.profileHelper.getPmcProfile(sessionID); + + for (const quest of allQuests) { + // Player already accepted the quest, show it regardless of status + const questInProfile = profile.Quests.find((x) => x.qid === quest._id); + if (questInProfile) { + quest.sptStatus = questInProfile.status; + questsToShowPlayer.push(quest); + continue; + } + + // Filter out bear quests for usec and vice versa + if (this.questIsForOtherSide(profile.Info.Side, quest._id)) { + continue; + } + + if (!this.showEventQuestToPlayer(quest._id)) { + continue; + } + + // Don't add quests that have a level higher than the user's + if (!this.playerLevelFulfillsQuestRequirement(quest, profile.Info.Level)) { + continue; + } + + // Player can use trader mods then remove them, leaving quests behind + const trader = profile.TradersInfo[quest.traderId]; + if (!trader) { + this.logger.debug( + `Unable to show quest: ${quest.QuestName} as its for a trader: ${quest.traderId} that no longer exists.`, + ); + + continue; + } + + const questRequirements = this.questConditionHelper.getQuestConditions(quest.conditions.AvailableForStart); + const loyaltyRequirements = this.questConditionHelper.getLoyaltyConditions( + quest.conditions.AvailableForStart, + ); + const standingRequirements = this.questConditionHelper.getStandingConditions( + quest.conditions.AvailableForStart, + ); + + // Quest has no conditions, standing or loyalty conditions, add to visible quest list + if ( + questRequirements.length === 0 && + loyaltyRequirements.length === 0 && + standingRequirements.length === 0 + ) { + quest.sptStatus = QuestStatus.AvailableForStart; + questsToShowPlayer.push(quest); + continue; + } + + // Check the status of each quest condition, if any are not completed + // then this quest should not be visible + let haveCompletedPreviousQuest = true; + for (const conditionToFulfil of questRequirements) { + // If the previous quest isn't in the user profile, it hasn't been completed or started + const prerequisiteQuest = profile.Quests.find((profileQuest) => + conditionToFulfil.target.includes(profileQuest.qid), + ); + if (!prerequisiteQuest) { + haveCompletedPreviousQuest = false; + break; + } + + // Prereq does not have its status requirement fulfilled + // Some bsg status ids are strings, MUST convert to number before doing includes check + if (!conditionToFulfil.status.map((status) => Number(status)).includes(prerequisiteQuest.status)) { + haveCompletedPreviousQuest = false; + break; + } + + // Has a wait timer + if (conditionToFulfil.availableAfter > 0) { + // Compare current time to unlock time for previous quest + const previousQuestCompleteTime = prerequisiteQuest.statusTimers[prerequisiteQuest.status]; + const unlockTime = previousQuestCompleteTime + conditionToFulfil.availableAfter; + if (unlockTime > this.timeUtil.getTimestamp()) { + this.logger.debug( + `Quest ${quest.QuestName} is locked for another ${ + unlockTime - this.timeUtil.getTimestamp() + } seconds`, + ); + } + } + } + + // Previous quest not completed, skip + if (!haveCompletedPreviousQuest) { + continue; + } + + let passesLoyaltyRequirements = true; + for (const condition of loyaltyRequirements) { + if (!this.traderLoyaltyLevelRequirementCheck(condition, profile)) { + passesLoyaltyRequirements = false; + break; + } + } + + let passesStandingRequirements = true; + for (const condition of standingRequirements) { + if (!this.traderStandingRequirementCheck(condition, profile)) { + passesStandingRequirements = false; + break; + } + } + + if (haveCompletedPreviousQuest && passesLoyaltyRequirements && passesStandingRequirements) { + quest.sptStatus = QuestStatus.AvailableForStart; + questsToShowPlayer.push(quest); + } + } + + return questsToShowPlayer; + } + + /** + * Return a list of quests that would fail when supplied quest is completed + * @param completedQuestId quest completed id + * @returns array of IQuest objects + */ + protected getQuestsFromProfileFailedByCompletingQuest(completedQuestId: string, pmcProfile: IPmcData): IQuest[] { + const questsInDb = this.getQuestsFromDb(); + return questsInDb.filter((quest) => { + // No fail conditions, skip + if (!quest.conditions.Fail || quest.conditions.Fail.length === 0) { + return false; + } + + // Quest already failed in profile, skip + if ( + pmcProfile.Quests.some( + (profileQuest) => profileQuest.qid === quest._id && profileQuest.status === QuestStatus.Fail, + ) + ) { + return false; + } + + return quest.conditions.Fail.some((condition) => condition.target?.includes(completedQuestId)); + }); + } + + /** + * Fail the provided quests + * Update quest in profile, otherwise add fresh quest object with failed status + * @param sessionID session id + * @param pmcData player profile + * @param questsToFail quests to fail + * @param output Client output + */ + protected failQuests( + sessionID: string, + pmcData: IPmcData, + questsToFail: IQuest[], + output: IItemEventRouterResponse, + ): void { + for (const questToFail of questsToFail) { + // Skip failing a quest that has a fail status of something other than success + if (questToFail.conditions.Fail?.some((x) => x.status?.some((status) => status !== QuestStatus.Success))) { + continue; + } + + const isActiveQuestInPlayerProfile = pmcData.Quests.find((quest) => quest.qid === questToFail._id); + if (isActiveQuestInPlayerProfile) { + if (isActiveQuestInPlayerProfile.status !== QuestStatus.Fail) { + const failBody: IFailQuestRequestData = { + Action: "QuestFail", + qid: questToFail._id, + removeExcessItems: true, + }; + this.failQuest(pmcData, failBody, sessionID, output); + } + } else { + // Failing an entirely new quest that doesnt exist in profile + const statusTimers = {}; + statusTimers[QuestStatus.Fail] = this.timeUtil.getTimestamp(); + const questData: IQuestStatus = { + qid: questToFail._id, + startTime: this.timeUtil.getTimestamp(), + statusTimers: statusTimers, + status: QuestStatus.Fail, + }; + pmcData.Quests.push(questData); + } + } + } + + /** + * Send a popup to player on successful completion of a quest + * @param sessionID session id + * @param pmcData Player profile + * @param completedQuestId Completed quest id + * @param questRewards Rewards given to player + */ + protected sendSuccessDialogMessageOnQuestComplete( + sessionID: string, + pmcData: IPmcData, + completedQuestId: string, + questRewards: IItem[], + ): void { + const quest = this.getQuestFromDb(completedQuestId, pmcData); + + this.mailSendService.sendLocalisedNpcMessageToPlayer( + sessionID, + this.traderHelper.getTraderById(quest.traderId), + MessageType.QUEST_SUCCESS, + quest.successMessageText, + questRewards, + this.timeUtil.getHoursAsSeconds(this.getMailItemRedeemTimeHoursForProfile(pmcData)), + ); + } + + /** + * Look for newly available quests after completing a quest with a requirement to wait x minutes (time-locked) before being available and add data to profile + * @param pmcData Player profile to update + * @param quests Quests to look for wait conditions in + * @param completedQuestId Quest just completed + */ + protected addTimeLockedQuestsToProfile(pmcData: IPmcData, quests: IQuest[], completedQuestId: string): void { + // Iterate over quests, look for quests with right criteria + for (const quest of quests) { + // If quest has prereq of completed quest + availableAfter value > 0 (quest has wait time) + const nextQuestWaitCondition = quest.conditions.AvailableForStart.find( + (x) => x.target?.includes(completedQuestId) && x.availableAfter > 0, + ); + if (nextQuestWaitCondition) { + // Now + wait time + const availableAfterTimestamp = this.timeUtil.getTimestamp() + nextQuestWaitCondition.availableAfter; + + // Update quest in profile with status of AvailableAfter + const existingQuestInProfile = pmcData.Quests.find((x) => x.qid === quest._id); + if (existingQuestInProfile) { + existingQuestInProfile.availableAfter = availableAfterTimestamp; + existingQuestInProfile.status = QuestStatus.AvailableAfter; + existingQuestInProfile.startTime = 0; + existingQuestInProfile.statusTimers = {}; + + continue; + } + + pmcData.Quests.push({ + qid: quest._id, + startTime: 0, + status: QuestStatus.AvailableAfter, + statusTimers: { + 9: this.timeUtil.getTimestamp(), + }, + availableAfter: availableAfterTimestamp, + }); + } + } + } + + /** + * Remove a quest entirely from a profile + * @param sessionId Player id + * @param questIdToRemove Qid of quest to remove + */ + protected removeQuestFromScavProfile(sessionId: string, questIdToRemove: string): void { + const fullProfile = this.profileHelper.getFullProfile(sessionId); + const repeatableInScavProfile = fullProfile.characters.scav.Quests?.find((x) => x.qid === questIdToRemove); + if (!repeatableInScavProfile) { + this.logger.warning( + this.localisationService.getText("quest-unable_to_remove_scav_quest_from_profile", { + scavQuestId: questIdToRemove, + profileId: sessionId, + }), + ); + + return; + } + + fullProfile.characters.scav.Quests.splice( + fullProfile.characters.scav.Quests.indexOf(repeatableInScavProfile), + 1, + ); + } + + /** + * Return quests that have different statuses + * @param preQuestStatusus Quests before + * @param postQuestStatuses Quests after + * @returns QuestStatusChange array + */ + protected getQuestsWithDifferentStatuses( + preQuestStatusus: IQuestStatus[], + postQuestStatuses: IQuestStatus[], + ): IQuestStatus[] | undefined { + const result: IQuestStatus[] = []; + + for (const quest of postQuestStatuses) { + // Add quest if status differs or quest not found + const preQuest = preQuestStatusus.find((x) => x.qid === quest.qid); + if (!preQuest || preQuest.status !== quest.status) { + result.push(quest); + } + } + + if (result.length === 0) { + return undefined; + } + + return result; + } + + /** + * Does a provided quest have a level requirement equal to or below defined level + * @param quest Quest to check + * @param playerLevel level of player to test against quest + * @returns true if quest can be seen/accepted by player of defined level + */ + protected playerLevelFulfillsQuestRequirement(quest: IQuest, playerLevel: number): boolean { + if (!quest.conditions) { + // No conditions + return true; + } + + const levelConditions = this.questConditionHelper.getLevelConditions(quest.conditions.AvailableForStart); + if (levelConditions.length) { + for (const levelCondition of levelConditions) { + if (!this.doesPlayerLevelFulfilCondition(playerLevel, levelCondition)) { + // Not valid, exit out + return false; + } + } + } + + // All conditions passed / has no level requirement, valid + return true; + } } diff --git a/project/src/services/LocationLifecycleService.ts b/project/src/services/LocationLifecycleService.ts index cb1a309b..522b59f6 100644 --- a/project/src/services/LocationLifecycleService.ts +++ b/project/src/services/LocationLifecycleService.ts @@ -6,6 +6,7 @@ import { PlayerScavGenerator } from "@spt/generators/PlayerScavGenerator"; import { HealthHelper } from "@spt/helpers/HealthHelper"; import { InRaidHelper } from "@spt/helpers/InRaidHelper"; import { ProfileHelper } from "@spt/helpers/ProfileHelper"; +import { QuestHelper } from "@spt/helpers/QuestHelper"; import { TraderHelper } from "@spt/helpers/TraderHelper"; import { ILocationBase } from "@spt/models/eft/common/ILocationBase"; import { IPmcData } from "@spt/models/eft/common/IPmcData"; @@ -68,6 +69,7 @@ export class LocationLifecycleService { @inject("DatabaseService") protected databaseService: DatabaseService, @inject("InRaidHelper") protected inRaidHelper: InRaidHelper, @inject("HealthHelper") protected healthHelper: HealthHelper, + @inject("QuestHelper") protected questHelper: QuestHelper, @inject("MatchBotDetailsCacheService") protected matchBotDetailsCacheService: MatchBotDetailsCacheService, @inject("PmcChatResponseService") protected pmcChatResponseService: PmcChatResponseService, @inject("PlayerScavGenerator") protected playerScavGenerator: PlayerScavGenerator, @@ -618,6 +620,7 @@ export class LocationLifecycleService { locationName: string, ): void { const postRaidProfile = request.results.profile; + const preRaidProfileQuestDataClone = this.cloner.clone(pmcProfile.Quests); // Update inventory this.inRaidHelper.setInventory(sessionId, pmcProfile, postRaidProfile, isSurvived, isTransfer); @@ -629,14 +632,18 @@ export class LocationLifecycleService { pmcProfile.TaskConditionCounters = postRaidProfile.TaskConditionCounters; pmcProfile.SurvivorClass = postRaidProfile.SurvivorClass; pmcProfile.Achievements = postRaidProfile.Achievements; - pmcProfile.Quests = this.processPostRaidQuests(postRaidProfile.Quests); + pmcProfile.Quests = this.processPostRaidQuests(postRaidProfile.Quests, pmcProfile.Quests); + + // Handle edge case - must occur AFTER processPostRaidQuests() + this.lightkeeperQuestWorkaround(sessionId, postRaidProfile.Quests, preRaidProfileQuestDataClone, pmcProfile); + pmcProfile.WishList = postRaidProfile.WishList; pmcProfile.Info.Experience = postRaidProfile.Info.Experience; this.applyTraderStandingAdjustments(pmcProfile.TradersInfo, postRaidProfile.TradersInfo); - // Must occur after experience is set and stats copied over + // Must occur AFTER experience is set and stats copied over pmcProfile.Stats.Eft.TotalSessionExperience = 0; const fenceId = Traders.FENCE; @@ -685,13 +692,53 @@ export class LocationLifecycleService { this.handleInsuredItemLostEvent(sessionId, pmcProfile, request, locationName); } + /** + * In 0.15 Lightkeeper quests do not give rewards in PvE, this issue also occurs in spt + * We check for newly completed Lk quests and run them through the servers `CompleteQuest` process + * This rewards players with items + craft unlocks + new trader assorts + * @param sessionId Session id + * @param postRaidQuests Quest statuses post-raid + * @param preRaidQuests Quest statuses pre-raid + * @param pmcProfile Players profile + */ + protected lightkeeperQuestWorkaround( + sessionId: string, + postRaidQuests: IQuestStatus[], + preRaidQuests: IQuestStatus[], + pmcProfile: IPmcData, + ): void { + // LK quests that were not completed before raid but now are + const newlyCompletedLightkeeperQuests = postRaidQuests.filter( + (postRaidQuest) => + postRaidQuest.status === QuestStatus.Success && + preRaidQuests.find( + (preRaidQuest) => + preRaidQuest.qid === postRaidQuest.qid && preRaidQuest.status !== QuestStatus.Success, + ) && + this.databaseService.getQuests()[postRaidQuest.qid].traderId === Traders.LIGHTHOUSEKEEPER, + ); + + // Run server complete quest process to ensure player gets rewards + for (const questToComplete of newlyCompletedLightkeeperQuests) { + this.questHelper.completeQuest( + pmcProfile, + { Action: "CompleteQuest", qid: questToComplete.qid, removeExcessItems: false }, + sessionId, + ); + } + } + /** * Convert post-raid quests into correct format * Quest status comes back as a string version of the enum `Success`, not the expected value of 1 - * @param questsToProcess + * @param questsToProcess quests data from client + * @param preRaidQuestStatuses quest data from before raid * @returns IQuestStatus */ - protected processPostRaidQuests(questsToProcess: IQuestStatus[]): IQuestStatus[] { + protected processPostRaidQuests( + questsToProcess: IQuestStatus[], + preRaidQuestStatuses: IQuestStatus[], + ): IQuestStatus[] { for (const quest of questsToProcess) { quest.status = Number(QuestStatus[quest.status]);