eff5f14e5c
## Added: * Config to keep daily quest type ## Changed: * changeRepeatableQuest to check if the daily quest type should be kept the same Co-authored-by: W1ngZ <W1ngZ@NoRealMail.com> Reviewed-on: https://dev.sp-tarkov.com/SPT/Server/pulls/400 Co-authored-by: W1ngZ <w1ngz@noreply.dev.sp-tarkov.com> Co-committed-by: W1ngZ <w1ngz@noreply.dev.sp-tarkov.com>
686 lines
30 KiB
TypeScript
686 lines
30 KiB
TypeScript
import { RepeatableQuestGenerator } from "@spt/generators/RepeatableQuestGenerator";
|
|
import { ProfileHelper } from "@spt/helpers/ProfileHelper";
|
|
import { QuestHelper } from "@spt/helpers/QuestHelper";
|
|
import { RepeatableQuestHelper } from "@spt/helpers/RepeatableQuestHelper";
|
|
import { IPmcData } from "@spt/models/eft/common/IPmcData";
|
|
import { IPmcDataRepeatableQuest, IRepeatableQuest } from "@spt/models/eft/common/tables/IRepeatableQuests";
|
|
import { IItemEventRouterResponse } from "@spt/models/eft/itemEvent/IItemEventRouterResponse";
|
|
import { ISptProfile } from "@spt/models/eft/profile/ISptProfile";
|
|
import { IRepeatableQuestChangeRequest } from "@spt/models/eft/quests/IRepeatableQuestChangeRequest";
|
|
import { ConfigTypes } from "@spt/models/enums/ConfigTypes";
|
|
import { ELocationName } from "@spt/models/enums/ELocationName";
|
|
import { HideoutAreas } from "@spt/models/enums/HideoutAreas";
|
|
import { QuestStatus } from "@spt/models/enums/QuestStatus";
|
|
import { SkillTypes } from "@spt/models/enums/SkillTypes";
|
|
import { IQuestConfig, IRepeatableQuestConfig } from "@spt/models/spt/config/IQuestConfig";
|
|
import { IGetRepeatableByIdResult } from "@spt/models/spt/quests/IGetRepeatableByIdResult";
|
|
import { IQuestTypePool } from "@spt/models/spt/repeatable/IQuestTypePool";
|
|
import { ILogger } from "@spt/models/spt/utils/ILogger";
|
|
import { EventOutputHolder } from "@spt/routers/EventOutputHolder";
|
|
import { ConfigServer } from "@spt/servers/ConfigServer";
|
|
import { DatabaseService } from "@spt/services/DatabaseService";
|
|
import { LocalisationService } from "@spt/services/LocalisationService";
|
|
import { PaymentService } from "@spt/services/PaymentService";
|
|
import { ProfileFixerService } from "@spt/services/ProfileFixerService";
|
|
import { HttpResponseUtil } from "@spt/utils/HttpResponseUtil";
|
|
import { ObjectId } from "@spt/utils/ObjectId";
|
|
import { RandomUtil } from "@spt/utils/RandomUtil";
|
|
import { TimeUtil } from "@spt/utils/TimeUtil";
|
|
import { ICloner } from "@spt/utils/cloners/ICloner";
|
|
import { inject, injectable } from "tsyringe";
|
|
|
|
@injectable()
|
|
export class RepeatableQuestController {
|
|
protected questConfig: IQuestConfig;
|
|
|
|
constructor(
|
|
@inject("PrimaryLogger") protected logger: ILogger,
|
|
@inject("DatabaseService") protected databaseService: DatabaseService,
|
|
@inject("TimeUtil") protected timeUtil: TimeUtil,
|
|
@inject("RandomUtil") protected randomUtil: RandomUtil,
|
|
@inject("HttpResponseUtil") protected httpResponse: HttpResponseUtil,
|
|
@inject("ProfileHelper") protected profileHelper: ProfileHelper,
|
|
@inject("ProfileFixerService") protected profileFixerService: ProfileFixerService,
|
|
@inject("LocalisationService") protected localisationService: LocalisationService,
|
|
@inject("EventOutputHolder") protected eventOutputHolder: EventOutputHolder,
|
|
@inject("PaymentService") protected paymentService: PaymentService,
|
|
@inject("ObjectId") protected objectId: ObjectId,
|
|
@inject("RepeatableQuestGenerator") protected repeatableQuestGenerator: RepeatableQuestGenerator,
|
|
@inject("RepeatableQuestHelper") protected repeatableQuestHelper: RepeatableQuestHelper,
|
|
@inject("QuestHelper") protected questHelper: QuestHelper,
|
|
@inject("ConfigServer") protected configServer: ConfigServer,
|
|
@inject("PrimaryCloner") protected cloner: ICloner,
|
|
) {
|
|
this.questConfig = this.configServer.getConfig(ConfigTypes.QUEST);
|
|
}
|
|
|
|
/**
|
|
* Handle client/repeatalbeQuests/activityPeriods
|
|
* Returns an array of objects in the format of repeatable quests to the client.
|
|
* repeatableQuestObject = {
|
|
* id: Unique Id,
|
|
* name: "Daily",
|
|
* endTime: the time when the quests expire
|
|
* activeQuests: currently available quests in an array. Each element of quest type format (see assets/database/templates/repeatableQuests.json).
|
|
* inactiveQuests: the quests which were previously active (required by client to fail them if they are not completed)
|
|
* }
|
|
*
|
|
* The method checks if the player level requirement for repeatable quests (e.g. daily lvl5, weekly lvl15) is met and if the previously active quests
|
|
* are still valid. This ischecked by endTime persisted in profile accordning to the resetTime configured for each repeatable kind (daily, weekly)
|
|
* in QuestCondig.js
|
|
*
|
|
* If the condition is met, new repeatableQuests are created, old quests (which are persisted in the profile.RepeatableQuests[i].activeQuests) are
|
|
* moved to profile.RepeatableQuests[i].inactiveQuests. This memory is required to get rid of old repeatable quest data in the profile, otherwise
|
|
* they'll litter the profile's Quests field.
|
|
* (if the are on "Succeed" but not "Completed" we keep them, to allow the player to complete them and get the rewards)
|
|
* The new quests generated are again persisted in profile.RepeatableQuests
|
|
*
|
|
* @param {string} sessionID Player's session id
|
|
*
|
|
* @returns {array} Array of "repeatableQuestObjects" as described above
|
|
*/
|
|
public getClientRepeatableQuests(sessionID: string): IPmcDataRepeatableQuest[] {
|
|
const returnData: Array<IPmcDataRepeatableQuest> = [];
|
|
const fullProfile = this.profileHelper.getFullProfile(sessionID);
|
|
const pmcData = fullProfile.characters.pmc;
|
|
const currentTime = this.timeUtil.getTimestamp();
|
|
|
|
// Daily / weekly / Daily_Savage
|
|
for (const repeatableConfig of this.questConfig.repeatableQuests) {
|
|
// Get daily/weekly data from profile, add empty object if missing
|
|
const generatedRepeatables = this.getRepeatableQuestSubTypeFromProfile(repeatableConfig, pmcData);
|
|
const repeatableTypeLower = repeatableConfig.name.toLowerCase();
|
|
|
|
const canAccessRepeatables = this.canProfileAccessRepeatableQuests(repeatableConfig, pmcData);
|
|
if (!canAccessRepeatables) {
|
|
// Dont send any repeatables, even existing ones
|
|
continue;
|
|
}
|
|
|
|
// Existing repeatables are still valid, add to return data and move to next sub-type
|
|
if (currentTime < generatedRepeatables.endTime - 1) {
|
|
returnData.push(generatedRepeatables);
|
|
|
|
this.logger.debug(`[Quest Check] ${repeatableTypeLower} quests are still valid.`);
|
|
|
|
continue;
|
|
}
|
|
|
|
// Current time is past expiry time
|
|
|
|
// Set endtime to be now + new duration
|
|
generatedRepeatables.endTime = currentTime + repeatableConfig.resetTime;
|
|
generatedRepeatables.inactiveQuests = [];
|
|
this.logger.debug(`Generating new ${repeatableTypeLower}`);
|
|
|
|
// Put old quests to inactive (this is required since only then the client makes them fail due to non-completion)
|
|
// Also need to push them to the "inactiveQuests" list since we need to remove them from offraidData.profile.Quests
|
|
// after a raid (the client seems to keep quests internally and we want to get rid of old repeatable quests)
|
|
// and remove them from the PMC's Quests and RepeatableQuests[i].activeQuests
|
|
this.processExpiredQuests(generatedRepeatables, pmcData);
|
|
|
|
// Create dynamic quest pool to avoid generating duplicates
|
|
const questTypePool = this.generateQuestPool(repeatableConfig, pmcData.Info.Level);
|
|
|
|
// Add repeatable quests of this loops sub-type (daily/weekly)
|
|
for (let i = 0; i < this.getQuestCount(repeatableConfig, pmcData); i++) {
|
|
let quest: IRepeatableQuest | undefined = undefined;
|
|
let lifeline = 0;
|
|
while (!quest && questTypePool.types.length > 0) {
|
|
quest = this.repeatableQuestGenerator.generateRepeatableQuest(
|
|
pmcData.Info.Level,
|
|
pmcData.TradersInfo,
|
|
questTypePool,
|
|
repeatableConfig,
|
|
);
|
|
lifeline++;
|
|
if (lifeline > 10) {
|
|
this.logger.debug(
|
|
"We were stuck in repeatable quest generation. This should never happen. Please report",
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// check if there are no more quest types available
|
|
if (questTypePool.types.length === 0) {
|
|
break;
|
|
}
|
|
quest.side = repeatableConfig.side;
|
|
generatedRepeatables.activeQuests.push(quest);
|
|
}
|
|
|
|
// Nullguard
|
|
fullProfile.spt.freeRepeatableRefreshUsedCount ||= {};
|
|
|
|
// Reset players free quest count for this repeatable sub-type as we're generating new repeatables for this group (daily/weekly)
|
|
fullProfile.spt.freeRepeatableRefreshUsedCount[repeatableTypeLower] = 0;
|
|
|
|
// Create stupid redundant change requirements from quest data
|
|
for (const quest of generatedRepeatables.activeQuests) {
|
|
generatedRepeatables.changeRequirement[quest._id] = {
|
|
changeCost: quest.changeCost,
|
|
changeStandingCost: this.randomUtil.getArrayValue([0, 0.01]), // Randomise standing cost to replace
|
|
};
|
|
}
|
|
|
|
// Reset free repeatable values in player profile to defaults
|
|
generatedRepeatables.freeChanges = repeatableConfig.freeChanges;
|
|
generatedRepeatables.freeChangesAvailable = repeatableConfig.freeChanges;
|
|
|
|
returnData.push({
|
|
id: repeatableConfig.id,
|
|
name: generatedRepeatables.name,
|
|
endTime: generatedRepeatables.endTime,
|
|
activeQuests: generatedRepeatables.activeQuests,
|
|
inactiveQuests: generatedRepeatables.inactiveQuests,
|
|
changeRequirement: generatedRepeatables.changeRequirement,
|
|
freeChanges: generatedRepeatables.freeChanges,
|
|
freeChangesAvailable: generatedRepeatables.freeChanges,
|
|
});
|
|
}
|
|
|
|
return returnData;
|
|
}
|
|
|
|
/**
|
|
* Expire quests and replace expired quests with ready-to-hand-in quests inside generatedRepeatables.activeQuests
|
|
* @param generatedRepeatables Repeatables to process (daily/weekly)
|
|
* @param pmcData Player profile
|
|
*/
|
|
protected processExpiredQuests(generatedRepeatables: IPmcDataRepeatableQuest, pmcData: IPmcData): void {
|
|
const questsToKeep = [];
|
|
for (const activeQuest of generatedRepeatables.activeQuests) {
|
|
const questStatusInProfile = pmcData.Quests.find((quest) => quest.qid === activeQuest._id);
|
|
if (!questStatusInProfile) {
|
|
continue;
|
|
}
|
|
|
|
// Keep finished quests in list so player can hand in
|
|
if (questStatusInProfile.status === QuestStatus.AvailableForFinish) {
|
|
questsToKeep.push(activeQuest);
|
|
this.logger.debug(
|
|
`Keeping repeatable quest: ${activeQuest._id} in activeQuests since it is available to hand in`,
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
// Clean up quest-related counters being left in profile
|
|
this.profileFixerService.removeDanglingConditionCounters(pmcData);
|
|
|
|
// Remove expired quest from pmc.quest array
|
|
pmcData.Quests = pmcData.Quests.filter((quest) => quest.qid !== activeQuest._id);
|
|
|
|
// Store in inactive array
|
|
generatedRepeatables.inactiveQuests.push(activeQuest);
|
|
}
|
|
|
|
generatedRepeatables.activeQuests = questsToKeep;
|
|
}
|
|
|
|
/**
|
|
* Check if a repeatable quest type (daily/weekly) is active for the given profile
|
|
* @param repeatableConfig Repeatable quest config
|
|
* @param pmcData Player profile
|
|
* @returns True if profile is allowed to access dailies
|
|
*/
|
|
protected canProfileAccessRepeatableQuests(repeatableConfig: IRepeatableQuestConfig, pmcData: IPmcData): boolean {
|
|
// PMC and daily quests not unlocked yet
|
|
if (repeatableConfig.side === "Pmc" && !this.playerHasDailyPmcQuestsUnlocked(pmcData, repeatableConfig)) {
|
|
return false;
|
|
}
|
|
|
|
// Scav and daily quests not unlocked yet
|
|
if (repeatableConfig.side === "Scav" && !this.playerHasDailyScavQuestsUnlocked(pmcData)) {
|
|
this.logger.debug("Daily scav quests still locked, Intel center not built");
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Does player have daily scav quests unlocked
|
|
* @param pmcData Player profile to check
|
|
* @returns True if unlocked
|
|
*/
|
|
protected playerHasDailyScavQuestsUnlocked(pmcData: IPmcData): boolean {
|
|
return (
|
|
pmcData?.Hideout?.Areas?.find((hideoutArea) => hideoutArea.type === HideoutAreas.INTEL_CENTER)?.level >= 1
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Does player have daily pmc quests unlocked
|
|
* @param pmcData Player profile to check
|
|
* @param repeatableConfig Config of daily type to check
|
|
* @returns True if unlocked
|
|
*/
|
|
protected playerHasDailyPmcQuestsUnlocked(pmcData: IPmcData, repeatableConfig: IRepeatableQuestConfig): boolean {
|
|
return pmcData.Info.Level >= repeatableConfig.minPlayerLevel;
|
|
}
|
|
|
|
/**
|
|
* Get the number of quests to generate - takes into account charisma state of player
|
|
* @param repeatableConfig Config
|
|
* @param pmcData Player profile
|
|
* @returns Quest count
|
|
*/
|
|
protected getQuestCount(repeatableConfig: IRepeatableQuestConfig, pmcData: IPmcData): number {
|
|
if (
|
|
repeatableConfig.name.toLowerCase() === "daily" &&
|
|
this.profileHelper.hasEliteSkillLevel(SkillTypes.CHARISMA, pmcData)
|
|
) {
|
|
// Elite charisma skill gives extra daily quest(s)
|
|
return (
|
|
repeatableConfig.numQuests +
|
|
this.databaseService.getGlobals().config.SkillsSettings.Charisma.BonusSettings.EliteBonusSettings
|
|
.RepeatableQuestExtraCount
|
|
);
|
|
}
|
|
|
|
return repeatableConfig.numQuests;
|
|
}
|
|
|
|
/**
|
|
* Get repeatable quest data from profile from name (daily/weekly), creates base repeatable quest object if none exists
|
|
* @param repeatableConfig daily/weekly config
|
|
* @param pmcData Profile to search
|
|
* @returns IPmcDataRepeatableQuest
|
|
*/
|
|
protected getRepeatableQuestSubTypeFromProfile(
|
|
repeatableConfig: IRepeatableQuestConfig,
|
|
pmcData: IPmcData,
|
|
): IPmcDataRepeatableQuest {
|
|
// Get from profile, add if missing
|
|
let repeatableQuestDetails = pmcData.RepeatableQuests.find(
|
|
(repeatable) => repeatable.name === repeatableConfig.name,
|
|
);
|
|
if (!repeatableQuestDetails) {
|
|
// Not in profile, generate
|
|
const hasAccess = this.profileHelper.hasAccessToRepeatableFreeRefreshSystem(pmcData);
|
|
repeatableQuestDetails = {
|
|
id: repeatableConfig.id,
|
|
name: repeatableConfig.name,
|
|
activeQuests: [],
|
|
inactiveQuests: [],
|
|
endTime: 0,
|
|
changeRequirement: {},
|
|
freeChanges: hasAccess ? repeatableConfig.freeChanges : 0,
|
|
freeChangesAvailable: hasAccess ? repeatableConfig.freeChangesAvailable : 0,
|
|
};
|
|
|
|
// Add base object that holds repeatable data to profile
|
|
pmcData.RepeatableQuests.push(repeatableQuestDetails);
|
|
}
|
|
|
|
return repeatableQuestDetails;
|
|
}
|
|
|
|
/**
|
|
* Just for debug reasons. Draws dailies a random assort of dailies extracted from dumps
|
|
*/
|
|
public generateDebugDailies(dailiesPool: any, factory: any, number: number): any {
|
|
let randomQuests = [];
|
|
let numberOfQuests = number;
|
|
|
|
if (factory) {
|
|
// First is factory extract always add for debugging
|
|
randomQuests.push(dailiesPool[0]);
|
|
numberOfQuests -= 1;
|
|
}
|
|
|
|
randomQuests = randomQuests.concat(this.randomUtil.drawRandomFromList(dailiesPool, numberOfQuests, false));
|
|
|
|
for (const element of randomQuests) {
|
|
element._id = this.objectId.generate();
|
|
const conditions = element.conditions.AvailableForFinish;
|
|
for (const condition of conditions) {
|
|
if ("counter" in condition._props) {
|
|
condition._props.counter.id = this.objectId.generate();
|
|
}
|
|
}
|
|
}
|
|
return randomQuests;
|
|
}
|
|
|
|
/**
|
|
* Used to create a quest pool during each cycle of repeatable quest generation. The pool will be subsequently
|
|
* narrowed down during quest generation to avoid duplicate quests. Like duplicate extractions or elimination quests
|
|
* where you have to e.g. kill scavs in same locations.
|
|
* @param repeatableConfig main repeatable quest config
|
|
* @param pmcLevel level of pmc generating quest pool
|
|
* @returns IQuestTypePool
|
|
*/
|
|
protected generateQuestPool(repeatableConfig: IRepeatableQuestConfig, pmcLevel: number): IQuestTypePool {
|
|
const questPool = this.createBaseQuestPool(repeatableConfig);
|
|
|
|
// Get the allowed locations based on the PMC's level
|
|
const locations = this.getAllowedLocationsForPmcLevel(repeatableConfig.locations, pmcLevel);
|
|
|
|
// Populate Exploration and Pickup quest locations
|
|
for (const location in locations) {
|
|
if (location !== ELocationName.ANY) {
|
|
questPool.pool.Exploration.locations[location] = locations[location];
|
|
questPool.pool.Pickup.locations[location] = locations[location];
|
|
}
|
|
}
|
|
|
|
// Add "any" to pickup quest pool
|
|
questPool.pool.Pickup.locations.any = ["any"];
|
|
|
|
const eliminationConfig = this.repeatableQuestHelper.getEliminationConfigByPmcLevel(pmcLevel, repeatableConfig);
|
|
const targetsConfig = this.repeatableQuestHelper.probabilityObjectArray(eliminationConfig.targets);
|
|
|
|
// Populate Elimination quest targets and their locations
|
|
for (const { data: target, key: targetKey } of targetsConfig) {
|
|
// Target is boss
|
|
if (target.isBoss) {
|
|
questPool.pool.Elimination.targets[targetKey] = { locations: ["any"] };
|
|
} else {
|
|
// Non-boss targets
|
|
const possibleLocations = Object.keys(locations);
|
|
|
|
const allowedLocations =
|
|
targetKey === "Savage"
|
|
? possibleLocations.filter((location) => location !== "laboratory") // Exclude labs for Savage targets.
|
|
: possibleLocations;
|
|
|
|
questPool.pool.Elimination.targets[targetKey] = { locations: allowedLocations };
|
|
}
|
|
}
|
|
|
|
return questPool;
|
|
}
|
|
|
|
protected createBaseQuestPool(repeatableConfig: IRepeatableQuestConfig): IQuestTypePool {
|
|
return {
|
|
types: repeatableConfig.types.slice(),
|
|
pool: { Exploration: { locations: {} }, Elimination: { targets: {} }, Pickup: { locations: {} } },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return the locations this PMC is allowed to get daily quests for based on their level
|
|
* @param locations The original list of locations
|
|
* @param pmcLevel The players level
|
|
* @returns A filtered list of locations that allow the player PMC level to access it
|
|
*/
|
|
protected getAllowedLocationsForPmcLevel(
|
|
locations: Record<ELocationName, string[]>,
|
|
pmcLevel: number,
|
|
): Partial<Record<ELocationName, string[]>> {
|
|
const allowedLocation: Partial<Record<ELocationName, string[]>> = {};
|
|
|
|
for (const location in locations) {
|
|
const locationNames = [];
|
|
for (const locationName of locations[location]) {
|
|
if (this.isPmcLevelAllowedOnLocation(locationName, pmcLevel)) {
|
|
locationNames.push(locationName);
|
|
}
|
|
}
|
|
|
|
if (locationNames.length > 0) {
|
|
allowedLocation[location] = locationNames;
|
|
}
|
|
}
|
|
|
|
return allowedLocation;
|
|
}
|
|
|
|
/**
|
|
* Return true if the given pmcLevel is allowed on the given location
|
|
* @param location The location name to check
|
|
* @param pmcLevel The level of the pmc
|
|
* @returns True if the given pmc level is allowed to access the given location
|
|
*/
|
|
protected isPmcLevelAllowedOnLocation(location: string, pmcLevel: number): boolean {
|
|
// All PMC levels are allowed for 'any' location requirement
|
|
if (location === ELocationName.ANY) {
|
|
return true;
|
|
}
|
|
|
|
const locationBase = this.databaseService.getLocation(location.toLowerCase())?.base;
|
|
if (!locationBase) {
|
|
return true;
|
|
}
|
|
|
|
return pmcLevel <= locationBase.RequiredPlayerLevelMax && pmcLevel >= locationBase.RequiredPlayerLevelMin;
|
|
}
|
|
|
|
public debugLogRepeatableQuestIds(pmcData: IPmcData): void {
|
|
for (const repeatable of pmcData.RepeatableQuests) {
|
|
const activeQuestsIds = [];
|
|
const inactiveQuestsIds = [];
|
|
for (const active of repeatable.activeQuests) {
|
|
activeQuestsIds.push(active._id);
|
|
}
|
|
|
|
for (const inactive of repeatable.inactiveQuests) {
|
|
inactiveQuestsIds.push(inactive._id);
|
|
}
|
|
|
|
this.logger.debug(`${repeatable.name} activeIds ${activeQuestsIds}`);
|
|
this.logger.debug(`${repeatable.name} inactiveIds ${inactiveQuestsIds}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle RepeatableQuestChange event
|
|
*
|
|
* Replace a players repeatable quest
|
|
* @param pmcData Player profile
|
|
* @param changeRequest Request object
|
|
* @param sessionID Session id
|
|
* @returns IItemEventRouterResponse
|
|
*/
|
|
public changeRepeatableQuest(
|
|
pmcData: IPmcData,
|
|
changeRequest: IRepeatableQuestChangeRequest,
|
|
sessionID: string,
|
|
): IItemEventRouterResponse {
|
|
const output = this.eventOutputHolder.getOutput(sessionID);
|
|
|
|
const fullProfile = this.profileHelper.getFullProfile(sessionID);
|
|
|
|
// Check for existing quest in (daily/weekly/scav arrays)
|
|
const { quest: questToReplace, repeatableType: repeatablesInProfile } = this.getRepeatableById(
|
|
changeRequest.qid,
|
|
pmcData,
|
|
);
|
|
|
|
// Subtype name of quest - daily/weekly/scav
|
|
const repeatableTypeLower = repeatablesInProfile.name.toLowerCase();
|
|
|
|
// Save for later standing loss calculation
|
|
const replacedQuestTraderId = questToReplace.traderId;
|
|
|
|
// Update active quests to exclude the quest we're replacing
|
|
repeatablesInProfile.activeQuests = repeatablesInProfile.activeQuests.filter(
|
|
(quest) => quest._id !== changeRequest.qid,
|
|
);
|
|
|
|
// Save for later cost calculation
|
|
const previousChangeRequirement = this.cloner.clone(repeatablesInProfile.changeRequirement[changeRequest.qid]);
|
|
|
|
// Delete the replaced quest change requrement as we're going to replace it
|
|
delete repeatablesInProfile.changeRequirement[changeRequest.qid];
|
|
|
|
// Get config for this repeatable sub-type (daily/weekly/scav)
|
|
const repeatableConfig = this.questConfig.repeatableQuests.find(
|
|
(config) => config.name === repeatablesInProfile.name,
|
|
);
|
|
|
|
// If the configuration dictates to replace with the same quest type, adjust the available quest types
|
|
if (repeatableConfig?.keepDailyQuestTypeOnReplacement) {
|
|
repeatableConfig.types = [questToReplace.type];
|
|
}
|
|
// Generate meta-data for what type/levelrange of quests can be generated for player
|
|
const allowedQuestTypes = this.generateQuestPool(repeatableConfig, pmcData.Info.Level);
|
|
const newRepeatableQuest = this.attemptToGenerateRepeatableQuest(pmcData, allowedQuestTypes, repeatableConfig);
|
|
if (!newRepeatableQuest) {
|
|
// Unable to find quest being replaced
|
|
const message = `Unable to generate repeatable quest of type: ${repeatableTypeLower} to replace trader: ${replacedQuestTraderId} quest ${changeRequest.qid}`;
|
|
this.logger.error(message);
|
|
|
|
return this.httpResponse.appendErrorToOutput(output, message);
|
|
}
|
|
|
|
// Add newly generated quest to daily/weekly/scav type array
|
|
newRepeatableQuest.side = repeatableConfig.side;
|
|
repeatablesInProfile.activeQuests.push(newRepeatableQuest);
|
|
|
|
// Find quest we're replacing in pmc profile quests array and remove it
|
|
this.questHelper.findAndRemoveQuestFromArrayIfExists(questToReplace._id, pmcData.Quests);
|
|
|
|
// Find quest we're replacing in scav profile quests array and remove it
|
|
this.questHelper.findAndRemoveQuestFromArrayIfExists(
|
|
questToReplace._id,
|
|
fullProfile.characters.scav?.Quests ?? [],
|
|
);
|
|
|
|
// Add new quests replacement cost to profile
|
|
repeatablesInProfile.changeRequirement[newRepeatableQuest._id] = {
|
|
changeCost: newRepeatableQuest.changeCost,
|
|
changeStandingCost: this.randomUtil.getArrayValue([0, 0.01]),
|
|
};
|
|
|
|
// Check if we should charge player for replacing quest
|
|
const isFreeToReplace = this.useFreeRefreshIfAvailable(fullProfile, repeatablesInProfile, repeatableTypeLower);
|
|
if (!isFreeToReplace) {
|
|
// Reduce standing with trader for not doing their quest
|
|
const traderOfReplacedQuest = pmcData.TradersInfo[replacedQuestTraderId];
|
|
traderOfReplacedQuest.standing -= previousChangeRequirement.changeStandingCost;
|
|
|
|
const charismaBonus = this.profileHelper.getSkillFromProfile(pmcData, SkillTypes.CHARISMA)?.Progress ?? 0;
|
|
for (const cost of previousChangeRequirement.changeCost) {
|
|
// Not free, Charge player + appy charisma bonus to cost of replacement
|
|
cost.count = Math.trunc(cost.count * (1 - Math.trunc(charismaBonus / 100) * 0.001) ?? 1);
|
|
this.paymentService.addPaymentToOutput(pmcData, cost.templateId, cost.count, sessionID, output);
|
|
if (output.warnings.length > 0) {
|
|
return output;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clone data before we send it to client
|
|
const repeatableToChangeClone = this.cloner.clone(repeatablesInProfile);
|
|
|
|
// Purge inactive repeatables
|
|
repeatableToChangeClone.inactiveQuests = [];
|
|
|
|
if (!repeatableToChangeClone) {
|
|
// Unable to find quest being replaced
|
|
const message = this.localisationService.getText("quest-unable_to_find_repeatable_to_replace");
|
|
this.logger.error(message);
|
|
|
|
return this.httpResponse.appendErrorToOutput(output, message);
|
|
}
|
|
|
|
// Nullguard
|
|
output.profileChanges[sessionID].repeatableQuests ||= [];
|
|
|
|
// Update client output with new repeatable
|
|
output.profileChanges[sessionID].repeatableQuests.push(repeatableToChangeClone);
|
|
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Find a repeatable (daily/weekly/scav) from a players profile by its id
|
|
* @param questId Id of quest to find
|
|
* @param pmcData Profile that contains quests to look through
|
|
* @returns IGetRepeatableByIdResult
|
|
*/
|
|
protected getRepeatableById(questId: string, pmcData: IPmcData): IGetRepeatableByIdResult {
|
|
for (const repeatablesInProfile of pmcData.RepeatableQuests) {
|
|
// Check for existing quest in (daily/weekly/scav arrays)
|
|
const questToReplace = repeatablesInProfile.activeQuests.find((repeatable) => repeatable._id === questId);
|
|
if (!questToReplace) {
|
|
// Not found, skip to next repeatable sub-type
|
|
continue;
|
|
}
|
|
|
|
return { quest: questToReplace, repeatableType: repeatablesInProfile };
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
protected attemptToGenerateRepeatableQuest(
|
|
pmcData: IPmcData,
|
|
questTypePool: IQuestTypePool,
|
|
repeatableConfig: IRepeatableQuestConfig,
|
|
): IRepeatableQuest {
|
|
const maxAttempts = 10;
|
|
let newRepeatableQuest: IRepeatableQuest = undefined;
|
|
let attempts = 0;
|
|
while (attempts < maxAttempts && questTypePool.types.length > 0) {
|
|
newRepeatableQuest = this.repeatableQuestGenerator.generateRepeatableQuest(
|
|
pmcData.Info.Level,
|
|
pmcData.TradersInfo,
|
|
questTypePool,
|
|
repeatableConfig,
|
|
);
|
|
|
|
if (newRepeatableQuest) {
|
|
// Successfully generated a quest, exit loop
|
|
break;
|
|
}
|
|
|
|
attempts++;
|
|
}
|
|
|
|
if (attempts > maxAttempts) {
|
|
this.logger.debug("We were stuck in repeatable quest generation. This should never happen. Please report");
|
|
}
|
|
|
|
return newRepeatableQuest;
|
|
}
|
|
|
|
/**
|
|
* Some accounts have access to free repeatable quest refreshes
|
|
* Track the usage of them inside players profile
|
|
* @param fullProfile Player profile
|
|
* @param repeatableSubType Can be daily / weekly / scav repeatable
|
|
* @param repeatableTypeName Subtype of repeatable quest: daily / weekly / scav
|
|
* @returns Is the repeatable being replaced for free
|
|
*/
|
|
protected useFreeRefreshIfAvailable(
|
|
fullProfile: ISptProfile,
|
|
repeatableSubType: IPmcDataRepeatableQuest,
|
|
repeatableTypeName: string,
|
|
): boolean {
|
|
// No free refreshes, exit early
|
|
if (repeatableSubType.freeChangesAvailable <= 0) {
|
|
// Reset counter to 0
|
|
repeatableSubType.freeChangesAvailable = 0;
|
|
|
|
return false;
|
|
}
|
|
|
|
// Only certain game versions have access to free refreshes
|
|
const hasAccessToFreeRefreshSystem = this.profileHelper.hasAccessToRepeatableFreeRefreshSystem(
|
|
fullProfile.characters.pmc,
|
|
);
|
|
|
|
// If the player has access and available refreshes:
|
|
if (hasAccessToFreeRefreshSystem) {
|
|
// Initialize/retrieve free refresh count for the desired subtype: daily/weekly
|
|
fullProfile.spt.freeRepeatableRefreshUsedCount ||= {};
|
|
const repeatableRefreshCounts = fullProfile.spt.freeRepeatableRefreshUsedCount;
|
|
repeatableRefreshCounts[repeatableTypeName] ||= 0; // Set to 0 if undefined
|
|
|
|
// Increment the used count and decrement the available count.
|
|
repeatableRefreshCounts[repeatableTypeName]++;
|
|
repeatableSubType.freeChangesAvailable--;
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|