Server/project/src/helpers/QuestHelper.ts

1113 lines
42 KiB
TypeScript
Raw Normal View History

2023-03-03 16:23:46 +01:00
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";
Implement live-like calculation of Weapon Maintenance XP (!164) - Implement formula based on 30 weapon repairs done on live - Return the repair amount as `repairAmount` from `repairItemByKit` - Add an additional `repairPoints` to `RepairDetails` to return the repair points used - Update `repairAmount` references to `repairPoints` to keep old behavior - Add new parameter to rewardSkillPoints that applies live-like level scaling - Only give weapon maintenance XP when using a repair kit This implementation comes with a "Crit Fail" and "Crit Success" mechanic to account for live sometimes randomly being -4 or +4 off from my estimated values. By default the chance of each is 10%, and they can overlap and cancel each other out Spreadsheet of live repair data: https://docs.google.com/spreadsheets/d/1-tR4WYelhZfKZ3ZDbxr3nd73Y60E1wQRjDWONpMVSew/edit?usp=sharing Useful columns: C: The amount of dura attempted to be repaired, this is used in the estimated skill calculated G: Hand entered value of how much skill gain I actually saw on live (Multiplied by 10 for readability. So "3.2" would be "32") J: The estimated skill gain, based on the calculation included in this merge request K: How far off the estimated skill gain was (Negative implies we guessed high and the actual result was lower) One thing of note: I've modified all the existing references to `repairAmount` to be the new `repairPoints` when a repair kit is used. This is to keep the existing behaviour outside of my direct changes as much as possible. However, this seems to be incorrect in some cases (For example, buff chance is repairPoints/maxDura, but repairPoints will go down the higher your int. I'm assuming this is meant to be repairedDura/maxDura). May want to update these references to use `repairAmount` once they've been confirmed to expect the repair amount instead of repair points used. Co-authored-by: DrakiaXYZ <565558+TheDgtl@users.noreply.github.com> Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/164 Co-authored-by: DrakiaXYZ <drakiaxyz@noreply.dev.sp-tarkov.com> Co-committed-by: DrakiaXYZ <drakiaxyz@noreply.dev.sp-tarkov.com>
2023-11-02 09:56:02 +01:00
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 { 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";
2023-03-03 16:23:46 +01:00
@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,
2023-03-03 16:23:46 +01:00
@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,
2023-03-03 16:23:46 +01:00
)
{
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
2023-03-03 16:23:46 +01:00
{
const quest = pmcData.Quests?.find((q) => q.qid === questId);
2023-03-03 16:23:46 +01:00
return quest ? quest.status : QuestStatus.Locked;
2023-03-03 16:23:46 +01:00
}
/**
* 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
2023-03-03 16:23:46 +01:00
{
if (condition.conditionType === "Level")
2023-03-03 16:23:46 +01:00
{
switch (condition.compareMethod)
2023-03-03 16:23:46 +01:00
{
case ">=":
return playerLevel >= <number>condition.value;
case ">":
return playerLevel > <number>condition.value;
case "<":
return playerLevel < <number>condition.value;
case "<=":
return playerLevel <= <number>condition.value;
case "=":
return playerLevel === <number>condition.value;
2023-03-03 16:23:46 +01:00
default:
this.logger.error(
this.localisationService.getText(
"quest-unable_to_find_compare_condition",
condition.compareMethod,
),
);
2023-03-03 16:23:46 +01:00
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;
}
Implement live-like calculation of Weapon Maintenance XP (!164) - Implement formula based on 30 weapon repairs done on live - Return the repair amount as `repairAmount` from `repairItemByKit` - Add an additional `repairPoints` to `RepairDetails` to return the repair points used - Update `repairAmount` references to `repairPoints` to keep old behavior - Add new parameter to rewardSkillPoints that applies live-like level scaling - Only give weapon maintenance XP when using a repair kit This implementation comes with a "Crit Fail" and "Crit Success" mechanic to account for live sometimes randomly being -4 or +4 off from my estimated values. By default the chance of each is 10%, and they can overlap and cancel each other out Spreadsheet of live repair data: https://docs.google.com/spreadsheets/d/1-tR4WYelhZfKZ3ZDbxr3nd73Y60E1wQRjDWONpMVSew/edit?usp=sharing Useful columns: C: The amount of dura attempted to be repaired, this is used in the estimated skill calculated G: Hand entered value of how much skill gain I actually saw on live (Multiplied by 10 for readability. So "3.2" would be "32") J: The estimated skill gain, based on the calculation included in this merge request K: How far off the estimated skill gain was (Negative implies we guessed high and the actual result was lower) One thing of note: I've modified all the existing references to `repairAmount` to be the new `repairPoints` when a repair kit is used. This is to keep the existing behaviour outside of my direct changes as much as possible. However, this seems to be incorrect in some cases (For example, buff chance is repairPoints/maxDura, but repairPoints will go down the higher your int. I'm assuming this is meant to be repairedDura/maxDura). May want to update these references to use `repairAmount` once they've been confirmed to expect the repair amount instead of repair points used. Co-authored-by: DrakiaXYZ <565558+TheDgtl@users.noreply.github.com> Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/164 Co-authored-by: DrakiaXYZ <drakiaxyz@noreply.dev.sp-tarkov.com> Co-committed-by: DrakiaXYZ <drakiaxyz@noreply.dev.sp-tarkov.com>
2023-11-02 09:56:02 +01:00
/**
* 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;
}
2023-03-03 16:23:46 +01:00
/**
* Get quest name by quest id
* @param questId id to get
* @returns
2023-03-03 16:23:46 +01:00
*/
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[<string>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
2023-03-03 16:23:46 +01:00
{
const requiredStanding = Number(questProperties.value);
const trader = profile.TradersInfo[<string>questProperties.target];
if (!trader)
{
this.logger.error(`Unable to find trader: ${questProperties.target} in profile`);
}
return this.compareAvailableForValues(trader.standing, requiredStanding, questProperties.compareMethod);
}
2023-03-03 16:23:46 +01:00
protected compareAvailableForValues(current: number, required: number, compareMethod: string): boolean
{
switch (compareMethod)
2023-03-03 16:23:46 +01:00
{
case ">=":
return current >= required;
2023-03-03 16:23:46 +01:00
case ">":
return current > required;
2023-03-03 16:23:46 +01:00
case "<=":
return current <= required;
2023-03-03 16:23:46 +01:00
case "<":
return current < required;
2023-03-03 16:23:46 +01:00
case "!=":
return current !== required;
2023-03-03 16:23:46 +01:00
case "==":
return current === required;
2023-03-03 16:23:46 +01:00
default:
this.logger.error(this.localisationService.getText("quest-compare_operator_unhandled", compareMethod));
2023-03-03 16:23:46 +01:00
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[]
2023-03-03 16:23:46 +01:00
{
/** item with mods to return */
let rewardItems: Item[] = [];
2023-03-03 16:23:46 +01:00
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);
}
}
2023-03-03 16:23:46 +01:00
for (const item of questReward.items)
2023-03-03 16:23:46 +01:00
{
if (!item.upd)
{
item.upd = {};
}
// Reward items are granted Found in Raid status
2023-03-03 16:23:46 +01:00
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
)
2023-03-03 16:23:46 +01:00
{
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;
}
2023-03-03 16:23:46 +01:00
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 items = [this.jsonUtil.clone(target)];
// Here we generate a new id for the root item
target._id = this.hashUtil.generate();
for (const mod of mods)
{
items.push(this.jsonUtil.clone(mod));
}
rewardItems = rewardItems.concat(this.itemHelper.reparentItemAndChildren(target, items));
2023-03-03 16:23:46 +01:00
}
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)
{
// Preset exists, use mods to hydrate reward item
const presetAndMods: Item[] = this.itemHelper.replaceIDs(null, this.jsonUtil.clone(defaultPreset._items));
const newRootId = this.itemHelper.remapRootItemId(presetAndMods);
questReward.items = presetAndMods;
// Remap target id to the new presets id
questReward.target = newRootId;
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);
}
2023-03-03 16:23:46 +01:00
/**
* 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)
2023-03-03 16:23:46 +01:00
* @returns array of items with the correct maxStack
*/
public getQuestRewardItems(quest: IQuest, status: QuestStatus): Item[]
2023-03-03 16:23:46 +01:00
{
// 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) : []
);
2023-03-03 16:23:46 +01:00
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
2023-03-03 16:23:46 +01:00
{
const currentTimestamp = this.timeUtil.getTimestamp();
const existingQuest = pmcData.Quests.find((q) => q.qid === acceptedQuest.qid);
2023-03-03 16:23:46 +01:00
if (existingQuest)
{
// Quest exists, update its status
existingQuest.startTime = currentTimestamp;
2023-03-03 16:23:46 +01:00
existingQuest.status = newState;
existingQuest.statusTimers[newState] = currentTimestamp;
existingQuest.completedConditions = [];
2023-03-03 16:23:46 +01:00
if (existingQuest.availableAfter)
{
delete existingQuest.availableAfter;
}
2023-03-03 16:23:46 +01:00
return existingQuest;
}
// Quest doesn't exists, add it
const newQuest: IQuestStatus = {
2023-03-03 16:23:46 +01:00
qid: acceptedQuest.qid,
startTime: currentTimestamp,
2023-03-03 16:23:46 +01:00
status: newState,
statusTimers: {},
2023-03-03 16:23:46 +01:00
};
2024-01-14 11:27:26 +01:00
// Check if quest has a prereq to be placed in a 'pending' state, otherwise set status timers value
2023-03-03 16:23:46 +01:00
const questDbData = this.getQuestFromDb(acceptedQuest.qid, pmcData);
2024-01-14 11:27:26 +01:00
if (!questDbData)
{
this.logger.error(`Quest: ${acceptedQuest.qid} of type: ${acceptedQuest.type} not found`);
}
const waitTime = questDbData?.conditions.AvailableForStart.find((x) => x.availableAfter > 0);
2023-03-03 16:23:46 +01:00
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;
2023-03-03 16:23:46 +01:00
}
else
{
newQuest.statusTimers[newState.toString()] = currentTimestamp;
2023-03-03 16:23:46 +01:00
newQuest.completedConditions = [];
}
return newQuest;
}
/**
* Get quests that can be shown to player after starting a quest
* @param startedQuestId Quest started by player
2023-03-03 16:23:46 +01:00
* @param sessionID Session id
* @returns Quests accessible to player incuding newly unlocked quests now quest (startedQuestId) was started
2023-03-03 16:23:46 +01:00
*/
public getNewlyAccessibleQuestsWhenStartingQuest(startedQuestId: string, sessionID: string): IQuest[]
2023-03-03 16:23:46 +01:00
{
// Get quest acceptance data from profile
const profile: IPmcData = this.profileHelper.getPmcProfile(sessionID);
const startedQuestInProfile = profile.Quests.find((x) => x.qid === startedQuestId);
2023-03-03 16:23:46 +01:00
// Get quests that
const eligibleQuests = this.getQuestsFromDb().filter((quest) =>
2023-03-03 16:23:46 +01:00
{
// 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);
});
2023-03-03 16:23:46 +01:00
// Not found, skip quest
2023-03-03 16:23:46 +01:00
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));
2023-03-03 16:23:46 +01:00
});
return this.getQuestsWithOnlyLevelRequirementStartCondition(eligibleQuests);
2023-03-03 16:23:46 +01:00
}
/**
* 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
2023-03-03 16:23:46 +01:00
*/
public failedUnlocked(failedQuestId: string, sessionId: string): IQuest[]
2023-03-03 16:23:46 +01:00
{
const profile = this.profileHelper.getPmcProfile(sessionId);
const profileQuest = profile.Quests.find((x) => x.qid === failedQuestId);
2023-03-03 16:23:46 +01:00
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;
});
2023-03-03 16:23:46 +01:00
if (!acceptedQuestCondition)
{
return false;
}
return profileQuest && (profileQuest.status === QuestStatus.Fail);
});
if (quests.length === 0)
{
return quests;
}
2023-03-03 16:23:46 +01:00
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
2023-03-03 16:23:46 +01:00
* @returns Updated quest
*/
public applyMoneyBoost(quest: IQuest, multiplier: number, questStatus: QuestStatus): IQuest
2023-03-03 16:23:46 +01:00
{
const rewards: IQuestReward[] = quest.rewards?.[QuestStatus[questStatus]] ?? [];
for (const reward of rewards)
2023-03-03 16:23:46 +01:00
{
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,
);
2023-03-03 16:23:46 +01:00
}
}
}
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
2023-03-03 16:23:46 +01:00
{
const inventoryItemIndex = pmcData.Inventory.items.findIndex((item) => item._id === itemId);
2023-03-03 16:23:46 +01:00
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 = {};
}
2023-03-03 16:23:46 +01:00
item.upd.StackObjectsCount = newStackSize;
this.addItemStackSizeChangeIntoEventResponse(output, sessionID, item);
2023-03-03 16:23:46 +01:00
}
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 });
2023-03-03 16:23:46 +01:00
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 },
});
}
2023-03-03 16:23:46 +01:00
/**
* 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"
);
2023-03-03 16:23:46 +01:00
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
2023-03-03 16:23:46 +01:00
* @returns Item event router response
*/
public failQuest(
pmcData: IPmcData,
failRequest: IFailQuestRequestData,
sessionID: string,
output: IItemEventRouterResponse = null,
): IItemEventRouterResponse
2023-03-03 16:23:46 +01:00
{
// Prepare response to send back client
if (!output)
{
output = this.eventOutputHolder.getOutput(sessionID);
}
2023-03-03 16:23:46 +01:00
this.updateQuestState(pmcData, QuestStatus.Fail, failRequest.qid);
const questRewards = this.applyQuestReward(pmcData, failRequest.qid, QuestStatus.Fail, sessionID, output);
2023-03-03 16:23:46 +01:00
// Create a dialog message for completing the quest.
const quest = this.getQuestFromDb(failRequest.qid, pmcData);
const questIsRepeatable = pmcData.RepeatableQuests.some((quest) => quest.id === failRequest.qid);
if (!questIsRepeatable)
{
this.mailSendService.sendLocalisedNpcMessageToPlayer(
sessionID,
this.traderHelper.getTraderById(quest.traderId),
MessageType.QUEST_FAIL,
quest.failMessageText,
questRewards,
this.timeUtil.getHoursAsSeconds(this.questConfig.redeemTime),
);
}
2023-03-03 16:23:46 +01:00
output.profileChanges[sessionID].quests.push(...this.failedUnlocked(failRequest.qid, sessionID));
2023-03-03 16:23:46 +01:00
return output;
2023-03-03 16:23:46 +01:00
}
/**
* 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 = <IQuest><unknown>repeatableType.activeQuests.find((x) => x._id === questId);
2023-03-03 16:23:46 +01:00
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
)
2023-03-03 16:23:46 +01:00
{
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);
2023-03-03 16:23:46 +01:00
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;
2023-11-30 10:36:28 +01:00
// 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 = [];
}
}
2023-03-03 16:23:46 +01:00
/**
* Give player quest rewards - Skills/exp/trader standing/items/assort unlocks - Returns reward items player earned
* @param profileData Player profile (scav or pmc)
2023-03-03 16:23:46 +01:00
* @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 [];
}
2023-03-03 16:23:46 +01:00
// Check for and apply intel center money bonus if it exists
const questMoneyRewardBonus = this.getQuestMoneyRewardBonus(pmcProfile);
if (questMoneyRewardBonus > 0)
2023-03-03 16:23:46 +01:00
{
// Apply additional bonus from hideout skill
questDetails = this.applyMoneyBoost(questDetails, questMoneyRewardBonus, state); // money = money + (money * intelCenterBonus / 100)
2023-03-03 16:23:46 +01:00
}
// e.g. 'Success' or 'AvailableForFinish'
const questStateAsString = QuestStatus[state];
for (const reward of <IQuestReward[]>questDetails.rewards[questStateAsString])
2023-03-03 16:23:46 +01:00
{
switch (reward.type)
{
case QuestRewardType.SKILL:
this.profileHelper.addSkillPointsToPlayer(
profileData,
reward.target as SkillTypes,
Number(reward.value),
);
2023-03-03 16:23:46 +01:00
break;
case QuestRewardType.EXPERIENCE:
this.profileHelper.addExperienceToPmc(sessionId, parseInt(<string>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(<string>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;
2023-03-03 16:23:46 +01:00
case QuestRewardType.PRODUCTIONS_SCHEME:
this.findAndAddHideoutProductionIdToProfile(
pmcProfile,
reward,
questDetails,
sessionId,
questResponse,
);
2023-03-03 16:23:46 +01:00
break;
default:
this.logger.error(
this.localisationService.getText("quest-reward_type_not_handled", {
rewardType: reward.type,
questId: questId,
questName: questDetails.QuestName,
}),
);
2023-03-03 16:23:46 +01:00
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
2023-03-03 16:23:46 +01:00
{
// 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
);
2023-03-03 16:23:46 +01:00
// More/less than 1 match, above filtering wasn't strict enough
2023-03-03 16:23:46 +01:00
if (matchingProductions.length !== 1)
{
this.logger.error(
this.localisationService.getText("quest-unable_to_find_matching_hideout_production", {
questName: questDetails.QuestName,
matchCount: matchingProductions.length,
}),
);
2023-03-03 16:23:46 +01:00
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
2023-03-03 16:23:46 +01:00
* @param pmcData player profile
* @returns bonus as a percent
*/
protected getQuestMoneyRewardBonus(pmcData: IPmcData): number
2023-03-03 16:23:46 +01:00
{
// Check player has intel center
const moneyRewardBonuses = pmcData.Bonuses.filter((x) => x.type === "QuestMoneyReward");
if (!moneyRewardBonuses)
2023-03-03 16:23:46 +01:00
{
return 0;
}
2023-03-03 16:23:46 +01:00
// 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)
2023-03-03 16:23:46 +01:00
}
return moneyRewardBonus;
2023-03-03 16:23:46 +01:00
}
/**
* Find quest with 'findItem' condition that needs the item tpl be handed in
2023-03-03 16:23:46 +01:00
* @param itemTpl item tpl to look for
* @param questIds Quests to search through for the findItem condition
* @returns quest id with 'FindItem' condition id
2023-03-03 16:23:46 +01:00
*/
public getFindItemConditionByQuestItem(
itemTpl: string,
questIds: string[],
allQuests: IQuest[],
): Record<string, string>
2023-03-03 16:23:46 +01:00
{
const result: Record<string, string> = {};
for (const questId of questIds)
2023-03-03 16:23:46 +01:00
{
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)
);
2023-03-03 16:23:46 +01:00
if (condition)
{
result[questId] = condition.id;
break;
2023-03-03 16:23:46 +01:00
}
}
return result;
2023-03-03 16:23:46 +01:00
}
/**
* 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 questKey in quests)
{
// Quest from db matches quests in profile, skip
const questData = quests[questKey];
if (pmcProfile.Quests.find((x) => x.qid === questData._id))
2023-03-03 16:23:46 +01:00
{
continue;
}
const statusesDict = {};
for (const status of statuses)
{
statusesDict[status] = this.timeUtil.getTimestamp();
}
const questRecordToAdd: IQuestStatus = {
2023-03-03 16:23:46 +01:00
qid: questKey,
startTime: this.timeUtil.getTimestamp(),
2023-03-03 16:23:46 +01:00
status: statuses[statuses.length - 1],
statusTimers: statusesDict,
completedConditions: [],
availableAfter: 0,
2023-03-03 16:23:46 +01:00
};
if (pmcProfile.Quests.some((x) => x.qid === questKey))
{
// Update existing
const existingQuest = pmcProfile.Quests.find((x) => x.qid === questKey);
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((x) => x.qid === questId);
if (pmcQuestToReplaceStatus)
{
quests.splice(quests.indexOf(pmcQuestToReplaceStatus, 1));
2023-03-03 16:23:46 +01:00
}
}
/**
* 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));
});
}
}