mirror of
https://github.com/sp-tarkov/server.git
synced 2025-02-13 09:50:43 -05:00
Co-authored-by: clodan <clodan@clodan.com> Co-authored-by: Dev <dev@dev.sp-tarkov.com> Co-authored-by: chomp <chomp@noreply.dev.sp-tarkov.com> Reviewed-on: SPT-AKI/Server#328 Co-authored-by: Alex <clodan@noreply.dev.sp-tarkov.com> Co-committed-by: Alex <clodan@noreply.dev.sp-tarkov.com>
934 lines
41 KiB
TypeScript
934 lines
41 KiB
TypeScript
import { inject, injectable } from "tsyringe";
|
|
import { RepeatableQuestRewardGenerator } from "@spt-aki/generators/RepeatableQuestRewardGenerator";
|
|
import { ItemHelper } from "@spt-aki/helpers/ItemHelper";
|
|
import { RepeatableQuestHelper } from "@spt-aki/helpers/RepeatableQuestHelper";
|
|
import { Exit } from "@spt-aki/models/eft/common/ILocationBase";
|
|
import { TraderInfo } from "@spt-aki/models/eft/common/tables/IBotBase";
|
|
import { IQuestCondition, IQuestConditionCounterCondition } from "@spt-aki/models/eft/common/tables/IQuest";
|
|
import { IRepeatableQuest } from "@spt-aki/models/eft/common/tables/IRepeatableQuests";
|
|
import { BaseClasses } from "@spt-aki/models/enums/BaseClasses";
|
|
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
|
|
import { Traders } from "@spt-aki/models/enums/Traders";
|
|
import {
|
|
IBossInfo,
|
|
IEliminationConfig,
|
|
IQuestConfig,
|
|
IRepeatableQuestConfig,
|
|
} from "@spt-aki/models/spt/config/IQuestConfig";
|
|
import { IQuestTypePool } from "@spt-aki/models/spt/repeatable/IQuestTypePool";
|
|
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
|
|
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
|
|
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
|
|
import { LocalisationService } from "@spt-aki/services/LocalisationService";
|
|
import { ICloner } from "@spt-aki/utils/cloners/ICloner";
|
|
import { MathUtil } from "@spt-aki/utils/MathUtil";
|
|
import { ObjectId } from "@spt-aki/utils/ObjectId";
|
|
import { ProbabilityObjectArray, RandomUtil } from "@spt-aki/utils/RandomUtil";
|
|
|
|
@injectable()
|
|
export class RepeatableQuestGenerator
|
|
{
|
|
protected questConfig: IQuestConfig;
|
|
|
|
constructor(
|
|
@inject("WinstonLogger") protected logger: ILogger,
|
|
@inject("RandomUtil") protected randomUtil: RandomUtil,
|
|
@inject("MathUtil") protected mathUtil: MathUtil,
|
|
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
|
|
@inject("ItemHelper") protected itemHelper: ItemHelper,
|
|
@inject("LocalisationService") protected localisationService: LocalisationService,
|
|
@inject("ObjectId") protected objectId: ObjectId,
|
|
@inject("RepeatableQuestHelper") protected repeatableQuestHelper: RepeatableQuestHelper,
|
|
@inject("RepeatableQuestRewardGenerator") protected repeatableQuestRewardGenerator:
|
|
RepeatableQuestRewardGenerator,
|
|
@inject("ConfigServer") protected configServer: ConfigServer,
|
|
@inject("RecursiveCloner") protected cloner: ICloner,
|
|
)
|
|
{
|
|
this.questConfig = this.configServer.getConfig(ConfigTypes.QUEST);
|
|
}
|
|
|
|
/**
|
|
* This method is called by /GetClientRepeatableQuests/ and creates one element of quest type format (see assets/database/templates/repeatableQuests.json).
|
|
* It randomly draws a quest type (currently Elimination, Completion or Exploration) as well as a trader who is providing the quest
|
|
* @param pmcLevel Player's level for requested items and reward generation
|
|
* @param pmcTraderInfo Players traper standing/rep levels
|
|
* @param questTypePool Possible quest types pool
|
|
* @param repeatableConfig Repeatable quest config
|
|
* @returns IRepeatableQuest
|
|
*/
|
|
public generateRepeatableQuest(
|
|
pmcLevel: number,
|
|
pmcTraderInfo: Record<string, TraderInfo>,
|
|
questTypePool: IQuestTypePool,
|
|
repeatableConfig: IRepeatableQuestConfig,
|
|
): IRepeatableQuest
|
|
{
|
|
const questType = this.randomUtil.drawRandomFromList<string>(questTypePool.types)[0];
|
|
|
|
// get traders from whitelist and filter by quest type availability
|
|
let traders = repeatableConfig.traderWhitelist.filter(x => x.questTypes.includes(questType)).map(x =>
|
|
x.traderId,
|
|
);
|
|
// filter out locked traders
|
|
traders = traders.filter(x => pmcTraderInfo[x].unlocked);
|
|
const traderId = this.randomUtil.drawRandomFromList(traders)[0];
|
|
|
|
switch (questType)
|
|
{
|
|
case "Elimination":
|
|
return this.generateEliminationQuest(pmcLevel, traderId, questTypePool, repeatableConfig);
|
|
case "Completion":
|
|
return this.generateCompletionQuest(pmcLevel, traderId, repeatableConfig);
|
|
case "Exploration":
|
|
return this.generateExplorationQuest(pmcLevel, traderId, questTypePool, repeatableConfig);
|
|
case "Pickup":
|
|
return this.generatePickupQuest(pmcLevel, traderId, questTypePool, repeatableConfig);
|
|
default:
|
|
throw new Error(`Unknown mission type ${questType}. Should never be here!`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a randomised Elimination quest
|
|
* @param pmcLevel Player's level for requested items and reward generation
|
|
* @param traderId Trader from which the quest will be provided
|
|
* @param questTypePool Pools for quests (used to avoid redundant quests)
|
|
* @param repeatableConfig The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig for the requestd quest
|
|
* @returns Object of quest type format for "Elimination" (see assets/database/templates/repeatableQuests.json)
|
|
*/
|
|
protected generateEliminationQuest(
|
|
pmcLevel: number,
|
|
traderId: string,
|
|
questTypePool: IQuestTypePool,
|
|
repeatableConfig: IRepeatableQuestConfig,
|
|
): IRepeatableQuest
|
|
{
|
|
const eliminationConfig = this.repeatableQuestHelper.getEliminationConfigByPmcLevel(pmcLevel, repeatableConfig);
|
|
const locationsConfig = repeatableConfig.locations;
|
|
let targetsConfig = this.repeatableQuestHelper.probabilityObjectArray(eliminationConfig.targets);
|
|
const bodypartsConfig = this.repeatableQuestHelper.probabilityObjectArray(eliminationConfig.bodyParts);
|
|
let weaponCategoryRequirementConfig = this.repeatableQuestHelper.probabilityObjectArray(
|
|
eliminationConfig.weaponCategoryRequirements,
|
|
);
|
|
const weaponRequirementConfig = this.repeatableQuestHelper.probabilityObjectArray(
|
|
eliminationConfig.weaponRequirements,
|
|
);
|
|
|
|
// the difficulty of the quest varies in difficulty depending on the condition
|
|
// possible conditions are
|
|
// - amount of npcs to kill
|
|
// - type of npc to kill (scav, boss, pmc)
|
|
// - with hit to what body part they should be killed
|
|
// - from what distance they should be killed
|
|
// a random combination of listed conditions can be required
|
|
// possible conditions elements and their relative probability can be defined in QuestConfig.js
|
|
// We use ProbabilityObjectArray to draw by relative probability. e.g. for targets:
|
|
// "targets": {
|
|
// "Savage": 7,
|
|
// "AnyPmc": 2,
|
|
// "bossBully": 0.5
|
|
// }
|
|
// higher is more likely. We define the difficulty to be the inverse of the relative probability.
|
|
|
|
// We want to generate a reward which is scaled by the difficulty of this mission. To get a upper bound with which we scale
|
|
// the actual difficulty we calculate the minimum and maximum difficulty (max being the sum of max of each condition type
|
|
// times the number of kills we have to perform):
|
|
|
|
// the minumum difficulty is the difficulty for the most probable (= easiest target) with no additional conditions
|
|
const minDifficulty = 1 / targetsConfig.maxProbability(); // min difficulty is lowest amount of scavs without any constraints
|
|
|
|
// Target on bodyPart max. difficulty is that of the least probable element
|
|
const maxTargetDifficulty = 1 / targetsConfig.minProbability();
|
|
const maxBodyPartsDifficulty = eliminationConfig.minKills / bodypartsConfig.minProbability();
|
|
|
|
// maxDistDifficulty is defined by 2, this could be a tuning parameter if we don't like the reward generation
|
|
const maxDistDifficulty = 2;
|
|
|
|
const maxKillDifficulty = eliminationConfig.maxKills;
|
|
|
|
function difficultyWeighing(
|
|
target: number,
|
|
bodyPart: number,
|
|
dist: number,
|
|
kill: number,
|
|
weaponRequirement: number,
|
|
): number
|
|
{
|
|
return Math.sqrt(Math.sqrt(target) + bodyPart + dist + weaponRequirement) * kill;
|
|
}
|
|
|
|
targetsConfig = targetsConfig.filter(x =>
|
|
Object.keys(questTypePool.pool.Elimination.targets).includes(x.key),
|
|
);
|
|
if (targetsConfig.length === 0 || targetsConfig.every(x => x.data.isBoss))
|
|
{
|
|
// There are no more targets left for elimination; delete it as a possible quest type
|
|
// also if only bosses are left we need to leave otherwise it's a guaranteed boss elimination
|
|
// -> then it would not be a quest with low probability anymore
|
|
questTypePool.types = questTypePool.types.filter(t => t !== "Elimination");
|
|
return null;
|
|
}
|
|
|
|
const targetKey = targetsConfig.draw()[0];
|
|
const targetDifficulty = 1 / targetsConfig.probability(targetKey);
|
|
|
|
let locations: string[] = questTypePool.pool.Elimination.targets[targetKey].locations;
|
|
|
|
// we use any as location if "any" is in the pool and we do not hit the specific location random
|
|
// we use any also if the random condition is not met in case only "any" was in the pool
|
|
let locationKey = "any";
|
|
if (
|
|
locations.includes("any")
|
|
&& (eliminationConfig.specificLocationProb < Math.random() || locations.length <= 1)
|
|
)
|
|
{
|
|
locationKey = "any";
|
|
delete questTypePool.pool.Elimination.targets[targetKey];
|
|
}
|
|
else
|
|
{
|
|
locations = locations.filter(l => l !== "any");
|
|
if (locations.length > 0)
|
|
{
|
|
locationKey = this.randomUtil.drawRandomFromList<string>(locations)[0];
|
|
questTypePool.pool.Elimination.targets[targetKey].locations = locations.filter(l =>
|
|
l !== locationKey,
|
|
);
|
|
if (questTypePool.pool.Elimination.targets[targetKey].locations.length === 0)
|
|
{
|
|
delete questTypePool.pool.Elimination.targets[targetKey];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// never should reach this if everything works out
|
|
this.logger.debug("Ecountered issue when creating Elimination quest. Please report.");
|
|
}
|
|
}
|
|
|
|
// draw the target body part and calculate the difficulty factor
|
|
let bodyPartsToClient = null;
|
|
let bodyPartDifficulty = 0;
|
|
if (eliminationConfig.bodyPartProb > Math.random())
|
|
{
|
|
// if we add a bodyPart condition, we draw randomly one or two parts
|
|
// each bodyPart of the BODYPARTS ProbabilityObjectArray includes the string(s) which need to be presented to the client in ProbabilityObjectArray.data
|
|
// e.g. we draw "Arms" from the probability array but must present ["LeftArm", "RightArm"] to the client
|
|
bodyPartsToClient = [];
|
|
const bodyParts = bodypartsConfig.draw(this.randomUtil.randInt(1, 3), false);
|
|
let probability = 0;
|
|
for (const bi of bodyParts)
|
|
{
|
|
// more than one part lead to an "OR" condition hence more parts reduce the difficulty
|
|
probability += bodypartsConfig.probability(bi);
|
|
for (const biClient of bodypartsConfig.data(bi))
|
|
{
|
|
bodyPartsToClient.push(biClient);
|
|
}
|
|
}
|
|
bodyPartDifficulty = 1 / probability;
|
|
}
|
|
|
|
// Draw a distance condition
|
|
let distance = null;
|
|
let distanceDifficulty = 0;
|
|
let isDistanceRequirementAllowed = !eliminationConfig.distLocationBlacklist.includes(locationKey);
|
|
|
|
if (targetsConfig.data(targetKey).isBoss)
|
|
{
|
|
// Get all boss spawn information
|
|
const bossSpawns = Object.values(this.databaseServer.getTables().locations).filter(x =>
|
|
"base" in x && "Id" in x.base,
|
|
).map(x => ({ Id: x.base.Id, BossSpawn: x.base.BossLocationSpawn }));
|
|
// filter for the current boss to spawn on map
|
|
const thisBossSpawns = bossSpawns.map(x => ({
|
|
Id: x.Id,
|
|
BossSpawn: x.BossSpawn.filter(e => e.BossName === targetKey),
|
|
})).filter(x => x.BossSpawn.length > 0);
|
|
// remove blacklisted locations
|
|
const allowedSpawns = thisBossSpawns.filter(x => !eliminationConfig.distLocationBlacklist.includes(x.Id));
|
|
// if the boss spawns on nom-blacklisted locations and the current location is allowed we can generate a distance kill requirement
|
|
isDistanceRequirementAllowed = isDistanceRequirementAllowed && allowedSpawns.length > 0;
|
|
}
|
|
|
|
if (eliminationConfig.distProb > Math.random() && isDistanceRequirementAllowed)
|
|
{
|
|
// Random distance with lower values more likely; simple distribution for starters...
|
|
distance = Math.floor(
|
|
Math.abs(Math.random() - Math.random()) * (1 + eliminationConfig.maxDist - eliminationConfig.minDist)
|
|
+ eliminationConfig.minDist,
|
|
);
|
|
distance = Math.ceil(distance / 5) * 5;
|
|
distanceDifficulty = maxDistDifficulty * distance / eliminationConfig.maxDist;
|
|
}
|
|
|
|
let allowedWeaponsCategory: string = undefined;
|
|
if (eliminationConfig.weaponCategoryRequirementProb > Math.random())
|
|
{
|
|
// Filter out close range weapons from far distance requirement
|
|
if (distance > 50)
|
|
{
|
|
weaponCategoryRequirementConfig = weaponCategoryRequirementConfig.filter(category =>
|
|
["Shotgun", "Pistol"].includes(category.key),
|
|
);
|
|
}
|
|
else if (distance < 20)
|
|
{ // Filter out far range weapons from close distance requirement
|
|
weaponCategoryRequirementConfig = weaponCategoryRequirementConfig.filter(category =>
|
|
["MarksmanRifle", "DMR"].includes(category.key),
|
|
);
|
|
}
|
|
|
|
// Pick a weighted weapon category
|
|
const weaponRequirement = weaponCategoryRequirementConfig.draw(1, false);
|
|
|
|
// Get the hideout id value stored in the .data array
|
|
allowedWeaponsCategory = weaponCategoryRequirementConfig.data(weaponRequirement[0])[0];
|
|
}
|
|
|
|
// Only allow a specific weapon requirement if a weapon category was not chosen
|
|
let allowedWeapon: string = undefined;
|
|
if (!allowedWeaponsCategory && eliminationConfig.weaponRequirementProb > Math.random())
|
|
{
|
|
const weaponRequirement = weaponRequirementConfig.draw(1, false);
|
|
const allowedWeaponsCategory = weaponRequirementConfig.data(weaponRequirement[0])[0];
|
|
const allowedWeapons = this.itemHelper.getItemTplsOfBaseType(allowedWeaponsCategory);
|
|
allowedWeapon = this.randomUtil.getArrayValue(allowedWeapons);
|
|
}
|
|
|
|
// Draw how many npm kills are required
|
|
const desiredKillCount = this.getEliminationKillCount(targetKey, targetsConfig, eliminationConfig);
|
|
const killDifficulty = desiredKillCount;
|
|
|
|
// not perfectly happy here; we give difficulty = 1 to the quest reward generation when we have the most diffucult mission
|
|
// e.g. killing reshala 5 times from a distance of 200m with a headshot.
|
|
const maxDifficulty = difficultyWeighing(1, 1, 1, 1, 1);
|
|
const curDifficulty = difficultyWeighing(
|
|
targetDifficulty / maxTargetDifficulty,
|
|
bodyPartDifficulty / maxBodyPartsDifficulty,
|
|
distanceDifficulty / maxDistDifficulty,
|
|
killDifficulty / maxKillDifficulty,
|
|
allowedWeaponsCategory || allowedWeapon ? 1 : 0,
|
|
);
|
|
|
|
// Aforementioned issue makes it a bit crazy since now all easier quests give significantly lower rewards than Completion / Exploration
|
|
// I therefore moved the mapping a bit up (from 0.2...1 to 0.5...2) so that normal difficulty still gives good reward and having the
|
|
// crazy maximum difficulty will lead to a higher difficulty reward gain factor than 1
|
|
const difficulty = this.mathUtil.mapToRange(curDifficulty, minDifficulty, maxDifficulty, 0.5, 2);
|
|
|
|
const quest = this.generateRepeatableTemplate("Elimination", traderId, repeatableConfig.side);
|
|
|
|
// ASSUMPTION: All fence quests are for scavs
|
|
if (traderId === Traders.FENCE)
|
|
{
|
|
quest.side = "Scav";
|
|
}
|
|
|
|
const availableForFinishCondition = quest.conditions.AvailableForFinish[0];
|
|
availableForFinishCondition.counter.id = this.objectId.generate();
|
|
availableForFinishCondition.counter.conditions = [];
|
|
|
|
// Only add specific location condition if specific map selected
|
|
if (locationKey !== "any")
|
|
{
|
|
availableForFinishCondition.counter.conditions.push(
|
|
this.generateEliminationLocation(locationsConfig[locationKey]),
|
|
);
|
|
}
|
|
availableForFinishCondition.counter.conditions.push(
|
|
this.generateEliminationCondition(
|
|
targetKey,
|
|
bodyPartsToClient,
|
|
distance,
|
|
allowedWeapon,
|
|
allowedWeaponsCategory,
|
|
),
|
|
);
|
|
availableForFinishCondition.value = desiredKillCount;
|
|
availableForFinishCondition.id = this.objectId.generate();
|
|
quest.location = this.getQuestLocationByMapId(locationKey);
|
|
|
|
quest.rewards = this.repeatableQuestRewardGenerator.generateReward(
|
|
pmcLevel,
|
|
Math.min(difficulty, 1),
|
|
traderId,
|
|
repeatableConfig,
|
|
eliminationConfig,
|
|
);
|
|
|
|
return quest;
|
|
}
|
|
|
|
/**
|
|
* Get a number of kills neded to complete elimination quest
|
|
* @param targetKey Target type desired e.g. anyPmc/bossBully/Savage
|
|
* @param targetsConfig Config
|
|
* @param eliminationConfig Config
|
|
* @returns Number of AI to kill
|
|
*/
|
|
protected getEliminationKillCount(
|
|
targetKey: string,
|
|
targetsConfig: ProbabilityObjectArray<string, IBossInfo>,
|
|
eliminationConfig: IEliminationConfig,
|
|
): number
|
|
{
|
|
if (targetsConfig.data(targetKey).isBoss)
|
|
{
|
|
return this.randomUtil.randInt(eliminationConfig.minBossKills, eliminationConfig.maxBossKills + 1);
|
|
}
|
|
|
|
if (targetsConfig.data(targetKey).isPmc)
|
|
{
|
|
return this.randomUtil.randInt(eliminationConfig.minPmcKills, eliminationConfig.maxPmcKills + 1);
|
|
}
|
|
|
|
return this.randomUtil.randInt(eliminationConfig.minKills, eliminationConfig.maxKills + 1);
|
|
}
|
|
|
|
/**
|
|
* A repeatable quest, besides some more or less static components, exists of reward and condition (see assets/database/templates/repeatableQuests.json)
|
|
* This is a helper method for GenerateEliminationQuest to create a location condition.
|
|
*
|
|
* @param {string} location the location on which to fulfill the elimination quest
|
|
* @returns {IEliminationCondition} object of "Elimination"-location-subcondition
|
|
*/
|
|
protected generateEliminationLocation(location: string[]): IQuestConditionCounterCondition
|
|
{
|
|
const propsObject: IQuestConditionCounterCondition = {
|
|
id: this.objectId.generate(),
|
|
dynamicLocale: true,
|
|
target: location,
|
|
conditionType: "Location",
|
|
};
|
|
|
|
return propsObject;
|
|
}
|
|
|
|
/**
|
|
* Create kill condition for an elimination quest
|
|
* @param target Bot type target of elimination quest e.g. "AnyPmc", "Savage"
|
|
* @param targetedBodyParts Body parts player must hit
|
|
* @param distance Distance from which to kill (currently only >= supported
|
|
* @param allowedWeapon What weapon must be used - undefined = any
|
|
* @param allowedWeaponCategory What category of weapon must be used - undefined = any
|
|
* @returns IEliminationCondition object
|
|
*/
|
|
protected generateEliminationCondition(
|
|
target: string,
|
|
targetedBodyParts: string[],
|
|
distance: number,
|
|
allowedWeapon: string,
|
|
allowedWeaponCategory: string,
|
|
): IQuestConditionCounterCondition
|
|
{
|
|
const killConditionProps: IQuestConditionCounterCondition = {
|
|
target: target,
|
|
value: 1,
|
|
id: this.objectId.generate(),
|
|
dynamicLocale: true,
|
|
conditionType: "Kills",
|
|
};
|
|
|
|
if (target.startsWith("boss"))
|
|
{
|
|
killConditionProps.target = "Savage";
|
|
killConditionProps.savageRole = [target];
|
|
}
|
|
|
|
// Has specific body part hit condition
|
|
if (targetedBodyParts)
|
|
{
|
|
killConditionProps.bodyPart = targetedBodyParts;
|
|
}
|
|
|
|
// Dont allow distance + melee requirement
|
|
if (distance && allowedWeaponCategory !== "5b5f7a0886f77409407a7f96")
|
|
{
|
|
killConditionProps.distance = { compareMethod: ">=", value: distance };
|
|
}
|
|
|
|
// Has specific weapon requirement
|
|
if (allowedWeapon)
|
|
{
|
|
killConditionProps.weapon = [allowedWeapon];
|
|
}
|
|
|
|
// Has specific weapon category requirement
|
|
if (allowedWeaponCategory?.length > 0)
|
|
{
|
|
// TODO - fix - does weaponCategories exist?
|
|
// killConditionProps.weaponCategories = [allowedWeaponCategory];
|
|
}
|
|
|
|
return killConditionProps;
|
|
}
|
|
|
|
/**
|
|
* Generates a valid Completion quest
|
|
*
|
|
* @param {integer} pmcLevel player's level for requested items and reward generation
|
|
* @param {string} traderId trader from which the quest will be provided
|
|
* @param {object} repeatableConfig The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig for the requestd quest
|
|
* @returns {object} object of quest type format for "Completion" (see assets/database/templates/repeatableQuests.json)
|
|
*/
|
|
protected generateCompletionQuest(
|
|
pmcLevel: number,
|
|
traderId: string,
|
|
repeatableConfig: IRepeatableQuestConfig,
|
|
): IRepeatableQuest
|
|
{
|
|
const completionConfig = repeatableConfig.questConfig.Completion;
|
|
const levelsConfig = repeatableConfig.rewardScaling.levels;
|
|
const roublesConfig = repeatableConfig.rewardScaling.roubles;
|
|
|
|
const distinctItemsToRetrieveCount = this.randomUtil.getInt(1, completionConfig.uniqueItemCount);
|
|
|
|
const quest = this.generateRepeatableTemplate("Completion", traderId, repeatableConfig.side);
|
|
|
|
// Filter the items.json items to items the player must retrieve to complete quest: shouldn't be a quest item or "non-existant"
|
|
const possibleItemsToRetrievePool = this.repeatableQuestRewardGenerator.getRewardableItems(
|
|
repeatableConfig,
|
|
traderId,
|
|
);
|
|
|
|
// Be fair, don't let the items be more expensive than the reward
|
|
let roublesBudget = Math.floor(
|
|
this.mathUtil.interp1(pmcLevel, levelsConfig, roublesConfig) * this.randomUtil.getFloat(0.5, 1),
|
|
);
|
|
roublesBudget = Math.max(roublesBudget, 5000);
|
|
let itemSelection = possibleItemsToRetrievePool.filter(x =>
|
|
this.itemHelper.getItemPrice(x[0]) < roublesBudget,
|
|
);
|
|
|
|
// We also have the option to use whitelist and/or blacklist which is defined in repeatableQuests.json as
|
|
// [{"minPlayerLevel": 1, "itemIds": ["id1",...]}, {"minPlayerLevel": 15, "itemIds": ["id3",...]}]
|
|
if (repeatableConfig.questConfig.Completion.useWhitelist)
|
|
{
|
|
const itemWhitelist
|
|
= this.databaseServer.getTables().templates.repeatableQuests.data.Completion.itemsWhitelist;
|
|
|
|
// Filter and concatenate the arrays according to current player level
|
|
const itemIdsWhitelisted = itemWhitelist.filter(p => p.minPlayerLevel <= pmcLevel).reduce(
|
|
(a, p) => a.concat(p.itemIds),
|
|
[],
|
|
);
|
|
itemSelection = itemSelection.filter((x) =>
|
|
{
|
|
// Whitelist can contain item tpls and item base type ids
|
|
return itemIdsWhitelisted.some(v => this.itemHelper.isOfBaseclass(x[0], v))
|
|
|| itemIdsWhitelisted.includes(x[0]);
|
|
});
|
|
// check if items are missing
|
|
// const flatList = itemSelection.reduce((a, il) => a.concat(il[0]), []);
|
|
// const missing = itemIdsWhitelisted.filter(l => !flatList.includes(l));
|
|
}
|
|
|
|
if (repeatableConfig.questConfig.Completion.useBlacklist)
|
|
{
|
|
const itemBlacklist
|
|
= this.databaseServer.getTables().templates.repeatableQuests.data.Completion.itemsBlacklist;
|
|
|
|
// we filter and concatenate the arrays according to current player level
|
|
const itemIdsBlacklisted = itemBlacklist.filter(p => p.minPlayerLevel <= pmcLevel).reduce(
|
|
(a, p) => a.concat(p.itemIds),
|
|
[],
|
|
);
|
|
|
|
itemSelection = itemSelection.filter((x) =>
|
|
{
|
|
return itemIdsBlacklisted.every(v => !this.itemHelper.isOfBaseclass(x[0], v))
|
|
|| !itemIdsBlacklisted.includes(x[0]);
|
|
});
|
|
}
|
|
|
|
if (itemSelection.length === 0)
|
|
{
|
|
this.logger.error(
|
|
this.localisationService.getText(
|
|
"repeatable-completion_quest_whitelist_too_small_or_blacklist_too_restrictive",
|
|
),
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
// Draw items to ask player to retrieve
|
|
let isAmmo = 0;
|
|
const randomNumbersUsed = [];
|
|
for (let i = 0; i < distinctItemsToRetrieveCount; i++)
|
|
{
|
|
let randomNumber = this.randomUtil.randInt(itemSelection.length);
|
|
while (randomNumbersUsed.includes(randomNumber) && randomNumbersUsed.length !== itemSelection.length)
|
|
{
|
|
randomNumber = this.randomUtil.randInt(itemSelection.length);
|
|
}
|
|
|
|
randomNumbersUsed.push(randomNumber);
|
|
|
|
const itemSelected = itemSelection[randomNumber];
|
|
const itemUnitPrice = this.itemHelper.getItemPrice(itemSelected[0]);
|
|
let minValue = completionConfig.minRequestedAmount;
|
|
let maxValue = completionConfig.maxRequestedAmount;
|
|
if (this.itemHelper.isOfBaseclass(itemSelected[0], BaseClasses.AMMO))
|
|
{
|
|
// Prevent multiple ammo requirements from being picked, stop after 6 attempts
|
|
if (isAmmo > 0 && isAmmo < 6)
|
|
{
|
|
isAmmo++;
|
|
i--;
|
|
continue;
|
|
}
|
|
isAmmo++;
|
|
minValue = completionConfig.minRequestedBulletAmount;
|
|
maxValue = completionConfig.maxRequestedBulletAmount;
|
|
}
|
|
let value = minValue;
|
|
|
|
// get the value range within budget
|
|
maxValue = Math.min(maxValue, Math.floor(roublesBudget / itemUnitPrice));
|
|
if (maxValue > minValue)
|
|
{
|
|
// if it doesn't blow the budget we have for the request, draw a random amount of the selected
|
|
// item type to be requested
|
|
value = this.randomUtil.randInt(minValue, maxValue + 1);
|
|
}
|
|
roublesBudget -= value * itemUnitPrice;
|
|
|
|
// push a CompletionCondition with the item and the amount of the item
|
|
quest.conditions.AvailableForFinish.push(this.generateCompletionAvailableForFinish(itemSelected[0], value));
|
|
|
|
if (roublesBudget > 0)
|
|
{
|
|
// reduce the list possible items to fulfill the new budget constraint
|
|
itemSelection = itemSelection.filter(x => this.itemHelper.getItemPrice(x[0]) < roublesBudget);
|
|
if (itemSelection.length === 0)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
quest.rewards = this.repeatableQuestRewardGenerator.generateReward(
|
|
pmcLevel,
|
|
1,
|
|
traderId,
|
|
repeatableConfig,
|
|
completionConfig,
|
|
);
|
|
|
|
return quest;
|
|
}
|
|
|
|
/**
|
|
* A repeatable quest, besides some more or less static components, exists of reward and condition (see assets/database/templates/repeatableQuests.json)
|
|
* This is a helper method for GenerateCompletionQuest to create a completion condition (of which a completion quest theoretically can have many)
|
|
*
|
|
* @param {string} itemTpl id of the item to request
|
|
* @param {integer} value amount of items of this specific type to request
|
|
* @returns {object} object of "Completion"-condition
|
|
*/
|
|
protected generateCompletionAvailableForFinish(itemTpl: string, value: number): IQuestCondition
|
|
{
|
|
let minDurability = 0;
|
|
let onlyFoundInRaid = true;
|
|
if (
|
|
this.itemHelper.isOfBaseclass(itemTpl, BaseClasses.WEAPON)
|
|
|| this.itemHelper.isOfBaseclass(itemTpl, BaseClasses.ARMOR)
|
|
)
|
|
{
|
|
minDurability = this.randomUtil.getArrayValue([60, 80]);
|
|
}
|
|
|
|
// By default all collected items must be FiR, except dog tags
|
|
if (
|
|
this.itemHelper.isOfBaseclass(itemTpl, BaseClasses.DOG_TAG_USEC)
|
|
|| this.itemHelper.isOfBaseclass(itemTpl, BaseClasses.DOG_TAG_BEAR)
|
|
)
|
|
{
|
|
onlyFoundInRaid = false;
|
|
}
|
|
|
|
return {
|
|
id: this.objectId.generate(),
|
|
parentId: "",
|
|
dynamicLocale: true,
|
|
index: 0,
|
|
visibilityConditions: [],
|
|
target: [itemTpl],
|
|
value: value,
|
|
minDurability: minDurability,
|
|
maxDurability: 100,
|
|
dogtagLevel: 0,
|
|
onlyFoundInRaid: onlyFoundInRaid,
|
|
conditionType: "HandoverItem",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generates a valid Exploration quest
|
|
*
|
|
* @param {integer} pmcLevel player's level for reward generation
|
|
* @param {string} traderId trader from which the quest will be provided
|
|
* @param {object} questTypePool Pools for quests (used to avoid redundant quests)
|
|
* @param {object} repeatableConfig The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig for the requestd quest
|
|
* @returns {object} object of quest type format for "Exploration" (see assets/database/templates/repeatableQuests.json)
|
|
*/
|
|
protected generateExplorationQuest(
|
|
pmcLevel: number,
|
|
traderId: string,
|
|
questTypePool: IQuestTypePool,
|
|
repeatableConfig: IRepeatableQuestConfig,
|
|
): IRepeatableQuest
|
|
{
|
|
const explorationConfig = repeatableConfig.questConfig.Exploration;
|
|
const requiresSpecificExtract
|
|
= Math.random() < repeatableConfig.questConfig.Exploration.specificExits.probability;
|
|
|
|
if (Object.keys(questTypePool.pool.Exploration.locations).length === 0)
|
|
{
|
|
// there are no more locations left for exploration; delete it as a possible quest type
|
|
questTypePool.types = questTypePool.types.filter(t => t !== "Exploration");
|
|
return null;
|
|
}
|
|
|
|
// If location drawn is factory, it's possible to either get factory4_day and factory4_night or only one
|
|
// of the both
|
|
const locationKey: string = this.randomUtil.drawRandomFromDict(questTypePool.pool.Exploration.locations)[0];
|
|
const locationTarget = questTypePool.pool.Exploration.locations[locationKey];
|
|
|
|
// Remove the location from the available pool
|
|
delete questTypePool.pool.Exploration.locations[locationKey];
|
|
|
|
// Different max extract count when specific extract needed
|
|
const exitTimesMax = requiresSpecificExtract
|
|
? explorationConfig.maxExtractsWithSpecificExit
|
|
: explorationConfig.maxExtracts + 1;
|
|
const numExtracts = this.randomUtil.randInt(1, exitTimesMax);
|
|
|
|
const quest = this.generateRepeatableTemplate("Exploration", traderId, repeatableConfig.side);
|
|
|
|
const exitStatusCondition: IQuestConditionCounterCondition = {
|
|
conditionType: "ExitStatus",
|
|
id: this.objectId.generate(),
|
|
dynamicLocale: true,
|
|
status: ["Survived"],
|
|
};
|
|
const locationCondition: IQuestConditionCounterCondition = {
|
|
conditionType: "Location",
|
|
id: this.objectId.generate(),
|
|
dynamicLocale: true,
|
|
target: locationTarget,
|
|
};
|
|
|
|
quest.conditions.AvailableForFinish[0].counter.id = this.objectId.generate();
|
|
quest.conditions.AvailableForFinish[0].counter.conditions = [exitStatusCondition, locationCondition];
|
|
quest.conditions.AvailableForFinish[0].value = numExtracts;
|
|
quest.conditions.AvailableForFinish[0].id = this.objectId.generate();
|
|
quest.location = this.getQuestLocationByMapId(locationKey);
|
|
|
|
if (requiresSpecificExtract)
|
|
{
|
|
// Fetch extracts for the requested side
|
|
const mapExits = this.getLocationExitsForSide(locationKey, repeatableConfig.side);
|
|
|
|
// Only get exits that have a greater than 0% chance to spawn
|
|
const exitPool = mapExits.filter(exit => exit.Chance > 0);
|
|
|
|
// Exclude exits with a requirement to leave (e.g. car extracts)
|
|
const possibleExits = exitPool.filter(exit => !("PassageRequirement" in exit)
|
|
|| repeatableConfig.questConfig.Exploration.specificExits.passageRequirementWhitelist.includes(
|
|
exit.PassageRequirement,
|
|
),
|
|
);
|
|
|
|
if (possibleExits.length === 0)
|
|
{
|
|
this.logger.error(
|
|
`Unable to choose specific exit on map: ${locationKey}, Possible exit pool was empty`,
|
|
);
|
|
}
|
|
else
|
|
{
|
|
// Choose one of the exits we filtered above
|
|
const chosenExit = this.randomUtil.drawRandomFromList(possibleExits, 1)[0];
|
|
|
|
// Create a quest condition to leave raid via chosen exit
|
|
const exitCondition = this.generateExplorationExitCondition(chosenExit);
|
|
quest.conditions.AvailableForFinish[0].counter.conditions.push(exitCondition);
|
|
}
|
|
}
|
|
|
|
// Difficulty for exploration goes from 1 extract to maxExtracts
|
|
// Difficulty for reward goes from 0.2...1 -> map
|
|
const difficulty = this.mathUtil.mapToRange(numExtracts, 1, explorationConfig.maxExtracts, 0.2, 1);
|
|
quest.rewards = this.repeatableQuestRewardGenerator.generateReward(
|
|
pmcLevel,
|
|
difficulty,
|
|
traderId,
|
|
repeatableConfig,
|
|
explorationConfig,
|
|
);
|
|
|
|
return quest;
|
|
}
|
|
|
|
/**
|
|
* Filter a maps exits to just those for the desired side
|
|
* @param locationKey Map id (e.g. factory4_day)
|
|
* @param playerSide Scav/Pmc
|
|
* @returns Array of Exit objects
|
|
*/
|
|
protected getLocationExitsForSide(locationKey: string, playerSide: string): Exit[]
|
|
{
|
|
const mapExtracts = this.databaseServer.getTables().locations[locationKey.toLocaleLowerCase()]
|
|
.allExtracts as Exit[];
|
|
|
|
return mapExtracts.filter(exit => exit.Side === playerSide);
|
|
}
|
|
|
|
protected generatePickupQuest(
|
|
pmcLevel: number,
|
|
traderId: string,
|
|
questTypePool: IQuestTypePool,
|
|
repeatableConfig: IRepeatableQuestConfig,
|
|
): IRepeatableQuest
|
|
{
|
|
const pickupConfig = repeatableConfig.questConfig.Pickup;
|
|
|
|
const quest = this.generateRepeatableTemplate("Pickup", traderId, repeatableConfig.side);
|
|
|
|
const itemTypeToFetchWithCount = this.randomUtil.getArrayValue(pickupConfig.ItemTypeToFetchWithMaxCount);
|
|
const itemCountToFetch = this.randomUtil.randInt(
|
|
itemTypeToFetchWithCount.minPickupCount,
|
|
itemTypeToFetchWithCount.maxPickupCount + 1,
|
|
);
|
|
// Choose location - doesnt seem to work for anything other than 'any'
|
|
// const locationKey: string = this.randomUtil.drawRandomFromDict(questTypePool.pool.Pickup.locations)[0];
|
|
// const locationTarget = questTypePool.pool.Pickup.locations[locationKey];
|
|
|
|
const findCondition = quest.conditions.AvailableForFinish.find(x => x.conditionType === "FindItem");
|
|
findCondition.target = [itemTypeToFetchWithCount.itemType];
|
|
findCondition.value = itemCountToFetch;
|
|
|
|
const counterCreatorCondition = quest.conditions.AvailableForFinish.find(x =>
|
|
x.conditionType === "CounterCreator",
|
|
);
|
|
// const locationCondition = counterCreatorCondition._props.counter.conditions.find(x => x._parent === "Location");
|
|
// (locationCondition._props as ILocationConditionProps).target = [...locationTarget];
|
|
|
|
const equipmentCondition = counterCreatorCondition.counter.conditions.find(x =>
|
|
x.conditionType === "Equipment",
|
|
);
|
|
equipmentCondition.equipmentInclusive = [[itemTypeToFetchWithCount.itemType]];
|
|
|
|
// Add rewards
|
|
quest.rewards = this.repeatableQuestRewardGenerator.generateReward(
|
|
pmcLevel,
|
|
1,
|
|
traderId,
|
|
repeatableConfig,
|
|
pickupConfig,
|
|
);
|
|
|
|
return quest;
|
|
}
|
|
|
|
/**
|
|
* Convert a location into an quest code can read (e.g. factory4_day into 55f2d3fd4bdc2d5f408b4567)
|
|
* @param locationKey e.g factory4_day
|
|
* @returns guid
|
|
*/
|
|
protected getQuestLocationByMapId(locationKey: string): string
|
|
{
|
|
return this.questConfig.locationIdMap[locationKey];
|
|
}
|
|
|
|
/**
|
|
* Exploration repeatable quests can specify a required extraction point.
|
|
* This method creates the according object which will be appended to the conditions array
|
|
*
|
|
* @param {string} exit The exit name to generate the condition for
|
|
* @returns {object} Exit condition
|
|
*/
|
|
protected generateExplorationExitCondition(exit: Exit): IQuestConditionCounterCondition
|
|
{
|
|
return { conditionType: "ExitName", exitName: exit.Name, id: this.objectId.generate(), dynamicLocale: true };
|
|
}
|
|
|
|
/**
|
|
* Generates the base object of quest type format given as templates in assets/database/templates/repeatableQuests.json
|
|
* The templates include Elimination, Completion and Extraction quest types
|
|
*
|
|
* @param {string} type Quest type: "Elimination", "Completion" or "Extraction"
|
|
* @param {string} traderId Trader from which the quest will be provided
|
|
* @param {string} side Scav daily or pmc daily/weekly quest
|
|
* @returns {object} Object which contains the base elements for repeatable quests of the requests type
|
|
* (needs to be filled with reward and conditions by called to make a valid quest)
|
|
*/
|
|
// @Incomplete: define Type for "type".
|
|
protected generateRepeatableTemplate(type: string, traderId: string, side: string): IRepeatableQuest
|
|
{
|
|
const questClone = this.cloner.clone<IRepeatableQuest>(
|
|
this.databaseServer.getTables().templates.repeatableQuests.templates[type],
|
|
);
|
|
questClone._id = this.objectId.generate();
|
|
questClone.traderId = traderId;
|
|
|
|
/* in locale, these id correspond to the text of quests
|
|
template ids -pmc : Elimination = 616052ea3054fc0e2c24ce6e / Completion = 61604635c725987e815b1a46 / Exploration = 616041eb031af660100c9967
|
|
template ids -scav : Elimination = 62825ef60e88d037dc1eb428 / Completion = 628f588ebb558574b2260fe5 / Exploration = 62825ef60e88d037dc1eb42c
|
|
*/
|
|
|
|
// Get template id from config based on side and type of quest
|
|
questClone.templateId = this.questConfig.questTemplateIds[side.toLowerCase()][type.toLowerCase()];
|
|
|
|
questClone.name = questClone.name.replace("{traderId}", traderId).replace(
|
|
"{templateId}",
|
|
questClone.templateId,
|
|
);
|
|
questClone.note = questClone.note.replace("{traderId}", traderId).replace(
|
|
"{templateId}",
|
|
questClone.templateId,
|
|
);
|
|
questClone.description = questClone.description.replace("{traderId}", traderId).replace(
|
|
"{templateId}",
|
|
questClone.templateId,
|
|
);
|
|
questClone.successMessageText = questClone.successMessageText.replace("{traderId}", traderId).replace(
|
|
"{templateId}",
|
|
questClone.templateId,
|
|
);
|
|
questClone.failMessageText = questClone.failMessageText.replace("{traderId}", traderId).replace(
|
|
"{templateId}",
|
|
questClone.templateId,
|
|
);
|
|
questClone.startedMessageText = questClone.startedMessageText.replace("{traderId}", traderId).replace(
|
|
"{templateId}",
|
|
questClone.templateId,
|
|
);
|
|
questClone.changeQuestMessageText = questClone.changeQuestMessageText.replace("{traderId}", traderId).replace(
|
|
"{templateId}",
|
|
questClone.templateId,
|
|
);
|
|
questClone.acceptPlayerMessage = questClone.acceptPlayerMessage.replace("{traderId}", traderId).replace(
|
|
"{templateId}",
|
|
questClone.templateId,
|
|
);
|
|
questClone.declinePlayerMessage = questClone.declinePlayerMessage.replace("{traderId}", traderId).replace(
|
|
"{templateId}",
|
|
questClone.templateId,
|
|
);
|
|
questClone.completePlayerMessage = questClone.completePlayerMessage.replace("{traderId}", traderId).replace(
|
|
"{templateId}",
|
|
questClone.templateId,
|
|
);
|
|
|
|
return questClone;
|
|
}
|
|
}
|