From da3b2944a7916c1a56e121872d64fff70514b577 Mon Sep 17 00:00:00 2001 From: Dev Date: Sun, 10 Mar 2024 23:15:03 +0000 Subject: [PATCH] Move reward code out of repeatable controller and into its own class --- project/src/di/Container.ts | 4 + .../generators/RepeatableQuestGenerator.ts | 513 +----------------- .../RepeatableQuestRewardGenerator.ts | 496 +++++++++++++++++ 3 files changed, 530 insertions(+), 483 deletions(-) create mode 100644 project/src/generators/RepeatableQuestRewardGenerator.ts diff --git a/project/src/di/Container.ts b/project/src/di/Container.ts index b006745b..89f33a70 100644 --- a/project/src/di/Container.ts +++ b/project/src/di/Container.ts @@ -76,6 +76,7 @@ import { PlayerScavGenerator } from "@spt-aki/generators/PlayerScavGenerator"; import { RagfairAssortGenerator } from "@spt-aki/generators/RagfairAssortGenerator"; import { RagfairOfferGenerator } from "@spt-aki/generators/RagfairOfferGenerator"; import { RepeatableQuestGenerator } from "@spt-aki/generators/RepeatableQuestGenerator"; +import { RepeatableQuestRewardGenerator } from "@spt-aki/generators/RepeatableQuestRewardGenerator"; import { ScavCaseRewardGenerator } from "@spt-aki/generators/ScavCaseRewardGenerator"; import { WeatherGenerator } from "@spt-aki/generators/WeatherGenerator"; import { BarrelInventoryMagGen } from "@spt-aki/generators/weapongen/implementations/BarrelInventoryMagGen"; @@ -520,6 +521,9 @@ export class Container depContainer.register("RepeatableQuestGenerator", { useClass: RepeatableQuestGenerator, }); + depContainer.register("RepeatableQuestRewardGenerator", { + useClass: RepeatableQuestRewardGenerator, + }); depContainer.register("BarrelInventoryMagGen", { useClass: BarrelInventoryMagGen }); depContainer.register("ExternalInventoryMagGen", { diff --git a/project/src/generators/RepeatableQuestGenerator.ts b/project/src/generators/RepeatableQuestGenerator.ts index c90aac97..718e5d6d 100644 --- a/project/src/generators/RepeatableQuestGenerator.ts +++ b/project/src/generators/RepeatableQuestGenerator.ts @@ -1,52 +1,31 @@ import { inject, injectable } from "tsyringe"; -import { HandbookHelper } from "@spt-aki/helpers/HandbookHelper"; +import { RepeatableQuestRewardGenerator } from "@spt-aki/generators/RepeatableQuestRewardGenerator"; import { ItemHelper } from "@spt-aki/helpers/ItemHelper"; -import { PresetHelper } from "@spt-aki/helpers/PresetHelper"; -import { ProfileHelper } from "@spt-aki/helpers/ProfileHelper"; -import { RagfairServerHelper } from "@spt-aki/helpers/RagfairServerHelper"; import { RepeatableQuestHelper } from "@spt-aki/helpers/RepeatableQuestHelper"; -import { IPreset } from "@spt-aki/models/eft/common/IGlobals"; import { Exit, ILocationBase } from "@spt-aki/models/eft/common/ILocationBase"; import { TraderInfo } from "@spt-aki/models/eft/common/tables/IBotBase"; -import { Item } from "@spt-aki/models/eft/common/tables/IItem"; -import { - IQuestCondition, - IQuestConditionCounterCondition, - IQuestReward, - IQuestRewards, -} from "@spt-aki/models/eft/common/tables/IQuest"; +import { IQuestCondition, IQuestConditionCounterCondition } from "@spt-aki/models/eft/common/tables/IQuest"; import { IRepeatableQuest } from "@spt-aki/models/eft/common/tables/IRepeatableQuests"; -import { ITemplateItem } from "@spt-aki/models/eft/common/tables/ITemplateItem"; import { BaseClasses } from "@spt-aki/models/enums/BaseClasses"; import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes"; -import { Money } from "@spt-aki/models/enums/Money"; -import { QuestRewardType } from "@spt-aki/models/enums/QuestRewardType"; import { Traders } from "@spt-aki/models/enums/Traders"; import { - IBaseQuestConfig, IBossInfo, IEliminationConfig, IQuestConfig, IRepeatableQuestConfig, } from "@spt-aki/models/spt/config/IQuestConfig"; import { IQuestTypePool } from "@spt-aki/models/spt/repeatable/IQuestTypePool"; -import { ExhaustableArray } from "@spt-aki/models/spt/server/ExhaustableArray"; 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 { ItemFilterService } from "@spt-aki/services/ItemFilterService"; import { LocalisationService } from "@spt-aki/services/LocalisationService"; -import { PaymentService } from "@spt-aki/services/PaymentService"; -import { ProfileFixerService } from "@spt-aki/services/ProfileFixerService"; -import { SeasonalEventService } from "@spt-aki/services/SeasonalEventService"; -import { HttpResponseUtil } from "@spt-aki/utils/HttpResponseUtil"; import { JsonUtil } from "@spt-aki/utils/JsonUtil"; import { MathUtil } from "@spt-aki/utils/MathUtil"; import { ObjectId } from "@spt-aki/utils/ObjectId"; import { ProbabilityObjectArray, RandomUtil } from "@spt-aki/utils/RandomUtil"; -import { TimeUtil } from "@spt-aki/utils/TimeUtil"; @injectable() export class RepeatableQuestGenerator @@ -54,26 +33,18 @@ export class RepeatableQuestGenerator protected questConfig: IQuestConfig; constructor( - @inject("TimeUtil") protected timeUtil: TimeUtil, @inject("WinstonLogger") protected logger: ILogger, @inject("RandomUtil") protected randomUtil: RandomUtil, - @inject("HttpResponseUtil") protected httpResponse: HttpResponseUtil, @inject("MathUtil") protected mathUtil: MathUtil, @inject("JsonUtil") protected jsonUtil: JsonUtil, @inject("DatabaseServer") protected databaseServer: DatabaseServer, @inject("ItemHelper") protected itemHelper: ItemHelper, - @inject("PresetHelper") protected presetHelper: PresetHelper, - @inject("ProfileHelper") protected profileHelper: ProfileHelper, - @inject("ProfileFixerService") protected profileFixerService: ProfileFixerService, - @inject("HandbookHelper") protected handbookHelper: HandbookHelper, - @inject("RagfairServerHelper") protected ragfairServerHelper: RagfairServerHelper, - @inject("EventOutputHolder") protected eventOutputHolder: EventOutputHolder, @inject("LocalisationService") protected localisationService: LocalisationService, - @inject("PaymentService") protected paymentService: PaymentService, @inject("ObjectId") protected objectId: ObjectId, @inject("ItemFilterService") protected itemFilterService: ItemFilterService, @inject("RepeatableQuestHelper") protected repeatableQuestHelper: RepeatableQuestHelper, - @inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService, + @inject("RepeatableQuestRewardGenerator") protected repeatableQuestRewardGenerator: + RepeatableQuestRewardGenerator, @inject("ConfigServer") protected configServer: ConfigServer, ) { @@ -381,7 +352,7 @@ export class RepeatableQuestGenerator availableForFinishCondition.id = this.objectId.generate(); quest.location = this.getQuestLocationByMapId(locationKey); - quest.rewards = this.generateReward( + quest.rewards = this.repeatableQuestRewardGenerator.generateReward( pmcLevel, Math.min(difficulty, 1), traderId, @@ -519,7 +490,10 @@ export class RepeatableQuestGenerator 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.getRewardableItems(repeatableConfig, traderId); + const possibleItemsToRetrievePool = this.repeatableQuestRewardGenerator.getRewardableItems( + repeatableConfig, + traderId, + ); // Be fair, don't let the items be more expensive than the reward let roublesBudget = Math.floor( @@ -640,7 +614,13 @@ export class RepeatableQuestGenerator } } - quest.rewards = this.generateReward(pmcLevel, 1, traderId, repeatableConfig, completionConfig); + quest.rewards = this.repeatableQuestRewardGenerator.generateReward( + pmcLevel, + 1, + traderId, + repeatableConfig, + completionConfig, + ); return quest; } @@ -795,7 +775,13 @@ export class RepeatableQuestGenerator // 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.generateReward(pmcLevel, difficulty, traderId, repeatableConfig, explorationConfig); + quest.rewards = this.repeatableQuestRewardGenerator.generateReward( + pmcLevel, + difficulty, + traderId, + repeatableConfig, + explorationConfig, + ); return quest; } @@ -868,7 +854,13 @@ export class RepeatableQuestGenerator equipmentCondition.equipmentInclusive = [[itemTypeToFetchWithCount.itemType]]; // Add rewards - quest.rewards = this.generateReward(pmcLevel, 1, traderId, repeatableConfig, pickupConfig); + quest.rewards = this.repeatableQuestRewardGenerator.generateReward( + pmcLevel, + 1, + traderId, + repeatableConfig, + pickupConfig, + ); return quest; } @@ -895,451 +887,6 @@ export class RepeatableQuestGenerator return { conditionType: "ExitName", exitName: exit.Name, id: this.objectId.generate(), dynamicLocale: true }; } - /** - * Generate the reward for a mission. A reward can consist of - * - Experience - * - Money - * - Items - * - Trader Reputation - * - * The reward is dependent on the player level as given by the wiki. The exact mapping of pmcLevel to - * experience / money / items / trader reputation can be defined in QuestConfig.js - * - * There's also a random variation of the reward the spread of which can be also defined in the config. - * - * Additionally, a scaling factor w.r.t. quest difficulty going from 0.2...1 can be used - * - * @param {integer} pmcLevel player's level - * @param {number} difficulty a reward scaling factor from 0.2 to 1 - * @param {string} traderId the trader for reputation gain (and possible in the future filtering of reward item type based on trader) - * @param {object} repeatableConfig The configuration for the repeatable kind (daily, weekly) as configured in QuestConfig for the requested quest - * @returns {object} object of "Reward"-type that can be given for a repeatable mission - */ - protected generateReward( - pmcLevel: number, - difficulty: number, - traderId: string, - repeatableConfig: IRepeatableQuestConfig, - questConfig: IBaseQuestConfig, - ): IQuestRewards - { - // difficulty could go from 0.2 ... -> for lowest difficulty receive 0.2*nominal reward - const levelsConfig = repeatableConfig.rewardScaling.levels; - const roublesConfig = repeatableConfig.rewardScaling.roubles; - const xpConfig = repeatableConfig.rewardScaling.experience; - const itemsConfig = repeatableConfig.rewardScaling.items; - const rewardSpreadConfig = repeatableConfig.rewardScaling.rewardSpread; - const skillRewardChanceConfig = repeatableConfig.rewardScaling.skillRewardChance; - const skillPointRewardConfig = repeatableConfig.rewardScaling.skillPointReward; - const reputationConfig = repeatableConfig.rewardScaling.reputation; - - const effectiveDifficulty = Number.isNaN(difficulty) ? 1 : difficulty; - if (Number.isNaN(difficulty)) - { - this.logger.warning(this.localisationService.getText("repeatable-difficulty_was_nan")); - } - - // rewards are generated based on pmcLevel, difficulty and a random spread - const rewardXP = Math.floor( - effectiveDifficulty * this.mathUtil.interp1(pmcLevel, levelsConfig, xpConfig) - * this.randomUtil.getFloat(1 - rewardSpreadConfig, 1 + rewardSpreadConfig), - ); - const rewardRoubles = Math.floor( - effectiveDifficulty * this.mathUtil.interp1(pmcLevel, levelsConfig, roublesConfig) - * this.randomUtil.getFloat(1 - rewardSpreadConfig, 1 + rewardSpreadConfig), - ); - const rewardNumItems = this.randomUtil.randInt( - 1, - Math.round(this.mathUtil.interp1(pmcLevel, levelsConfig, itemsConfig)) + 1, - ); - const rewardReputation = - Math.round( - 100 * effectiveDifficulty * this.mathUtil.interp1(pmcLevel, levelsConfig, reputationConfig) - * this.randomUtil.getFloat(1 - rewardSpreadConfig, 1 + rewardSpreadConfig), - ) / 100; - const skillRewardChance = this.mathUtil.interp1(pmcLevel, levelsConfig, skillRewardChanceConfig); - const skillPointReward = this.mathUtil.interp1(pmcLevel, levelsConfig, skillPointRewardConfig); - - // Possible improvement -> draw trader-specific items e.g. with this.itemHelper.isOfBaseclass(val._id, ItemHelper.BASECLASS.FoodDrink) - let roublesBudget = rewardRoubles; - let rewardItemPool = this.chooseRewardItemsWithinBudget(repeatableConfig, roublesBudget, traderId); - - const rewards: IQuestRewards = { Started: [], Success: [], Fail: [] }; - - let rewardIndex = 0; - // Add xp reward - if (rewardXP > 0) - { - rewards.Success.push({ value: rewardXP, type: QuestRewardType.EXPERIENCE, index: rewardIndex }); - rewardIndex++; - } - - // Add money reward - this.addMoneyReward(traderId, rewards, rewardRoubles, rewardIndex); - rewardIndex++; - - const traderWhitelistDetails = repeatableConfig.traderWhitelist.find((x) => x.traderId === traderId); - if ( - traderWhitelistDetails.rewardCanBeWeapon - && this.randomUtil.getChance100(traderWhitelistDetails.weaponRewardChancePercent) - ) - { - // Add a random default preset weapon as reward - const defaultPresetPool = new ExhaustableArray( - Object.values(this.presetHelper.getDefaultWeaponPresets()), - this.randomUtil, - this.jsonUtil, - ); - let chosenPreset: IPreset; - while (defaultPresetPool.hasValues()) - { - const randomPreset = defaultPresetPool.getRandomValue(); - const tpls = randomPreset._items.map((item) => item._tpl); - const presetPrice = this.itemHelper.getItemAndChildrenPrice(tpls); - if (presetPrice <= roublesBudget) - { - chosenPreset = this.jsonUtil.clone(randomPreset); - break; - } - } - - if (chosenPreset) - { - // use _encyclopedia as its always the base items _tpl, items[0] isn't guaranteed to be base item - rewards.Success.push( - this.generateRewardItem(chosenPreset._encyclopedia, 1, rewardIndex, chosenPreset._items), - ); - rewardIndex++; - } - } - - if (rewardItemPool.length > 0) - { - for (let i = 0; i < rewardNumItems; i++) - { - let rewardItemStackCount = 1; - const itemSelected = rewardItemPool[this.randomUtil.randInt(rewardItemPool.length)]; - - if (this.itemHelper.isOfBaseclass(itemSelected._id, BaseClasses.AMMO)) - { - // Don't reward ammo that stacks to less than what's defined in config - if (itemSelected._props.StackMaxSize < repeatableConfig.rewardAmmoStackMinSize) - { - continue; - } - - // Choose smallest value between budget fitting size and stack max - rewardItemStackCount = this.calculateAmmoStackSizeThatFitsBudget( - itemSelected, - roublesBudget, - rewardNumItems, - ); - } - - // 25% chance to double, triple quadruple reward stack (Only occurs when item is stackable and not weapon, armor or ammo) - if (this.canIncreaseRewardItemStackSize(itemSelected, 70000)) - { - rewardItemStackCount = this.getRandomisedRewardItemStackSizeByPrice(itemSelected); - } - - rewards.Success.push(this.generateRewardItem(itemSelected._id, rewardItemStackCount, rewardIndex)); - rewardIndex++; - - const itemCost = this.itemHelper.getStaticItemPrice(itemSelected._id); - roublesBudget -= rewardItemStackCount * itemCost; - - // If we still have budget narrow down possible items - if (roublesBudget > 0) - { - // Filter possible reward items to only items with a price below the remaining budget - rewardItemPool = rewardItemPool.filter((x) => - this.itemHelper.getStaticItemPrice(x._id) < roublesBudget - ); - if (rewardItemPool.length === 0) - { - break; // No reward items left, exit - } - } - else - { - break; - } - } - } - - // Add rep reward to rewards array - if (rewardReputation > 0) - { - const reward: IQuestReward = { - target: traderId, - value: rewardReputation, - type: QuestRewardType.TRADER_STANDING, - index: rewardIndex, - }; - rewards.Success.push(reward); - rewardIndex++; - } - - // Chance of adding skill reward - if (this.randomUtil.getChance100(skillRewardChance * 100)) - { - const reward: IQuestReward = { - target: this.randomUtil.getArrayValue(questConfig.possibleSkillRewards), - value: skillPointReward, - type: QuestRewardType.SKILL, - index: rewardIndex, - }; - rewards.Success.push(reward); - } - - return rewards; - } - - protected addMoneyReward(traderId: string, rewards: IQuestRewards, rewardRoubles: number, rewardIndex: number): void - { - // PK and Fence use euros - if (traderId === Traders.PEACEKEEPER || traderId === Traders.FENCE) - { - rewards.Success.push( - this.generateRewardItem( - Money.EUROS, - this.handbookHelper.fromRUB(rewardRoubles, Money.EUROS), - rewardIndex, - ), - ); - } - else - { - // Everyone else uses roubles - rewards.Success.push(this.generateRewardItem(Money.ROUBLES, rewardRoubles, rewardIndex)); - } - } - - protected calculateAmmoStackSizeThatFitsBudget( - itemSelected: ITemplateItem, - roublesBudget: number, - rewardNumItems: number, - ): number - { - // The budget for this ammo stack - const stackRoubleBudget = roublesBudget / rewardNumItems; - - const singleCartridgePrice = this.handbookHelper.getTemplatePrice(itemSelected._id); - - // Get a stack size of ammo that fits rouble budget - const stackSizeThatFitsBudget = Math.round(stackRoubleBudget / singleCartridgePrice); - - // Get itemDbs max stack size for ammo - don't go above 100 (some mods mess around with stack sizes) - const stackMaxCount = Math.min(itemSelected._props.StackMaxSize, 100); - - return Math.min(stackSizeThatFitsBudget, stackMaxCount); - } - - /** - * Should reward item have stack size increased (25% chance) - * @param item Item to possibly increase stack size of - * @param maxRoublePriceToStack Maximum rouble price an item can be to still be chosen for stacking - * @returns True if it should - */ - protected canIncreaseRewardItemStackSize(item: ITemplateItem, maxRoublePriceToStack: number): boolean - { - return this.itemHelper.getStaticItemPrice(item._id) < maxRoublePriceToStack - && !this.itemHelper.isOfBaseclasses(item._id, [ - BaseClasses.WEAPON, - BaseClasses.ARMORED_EQUIPMENT, - BaseClasses.AMMO, - ]) - && !this.itemHelper.itemRequiresSoftInserts(item._id) - && this.randomUtil.getChance100(25); - } - - /** - * Get a randomised number a reward items stack size should be based on its handbook price - * @param item Reward item to get stack size for - * @returns Stack size value - */ - protected getRandomisedRewardItemStackSizeByPrice(item: ITemplateItem): number - { - const rewardItemPrice = this.itemHelper.getStaticItemPrice(item._id); - if (rewardItemPrice < 3000) - { - return this.randomUtil.getArrayValue([2, 3, 4]); - } - - if (rewardItemPrice < 10000) - { - return this.randomUtil.getArrayValue([2, 3]); - } - - return 2; - } - - /** - * Select a number of items that have a colelctive value of the passed in parameter - * @param repeatableConfig Config - * @param roublesBudget Total value of items to return - * @returns Array of reward items that fit budget - */ - protected chooseRewardItemsWithinBudget( - repeatableConfig: IRepeatableQuestConfig, - roublesBudget: number, - traderId: string, - ): ITemplateItem[] - { - // First filter for type and baseclass to avoid lookup in handbook for non-available items - const rewardableItemPool = this.getRewardableItems(repeatableConfig, traderId); - const minPrice = Math.min(25000, 0.5 * roublesBudget); - - let rewardableItemPoolWithinBudget = rewardableItemPool.filter((item) => - { - // Get default preset if it exists - const defaultPreset = this.presetHelper.getDefaultPreset(item[0]); - - // Bundle up tpls we want price for - const tpls = defaultPreset ? defaultPreset._items.map((item) => item._tpl) : [item[0]]; - - // Get price of tpls - const itemPrice = this.itemHelper.getItemAndChildrenPrice(tpls); - - return itemPrice < roublesBudget && itemPrice > minPrice; - }).map((x) => x[1]); - - if (rewardableItemPoolWithinBudget.length === 0) - { - this.logger.warning( - this.localisationService.getText("repeatable-no_reward_item_found_in_price_range", { - minPrice: minPrice, - roublesBudget: roublesBudget, - }), - ); - // In case we don't find any items in the price range - rewardableItemPoolWithinBudget = rewardableItemPool.filter((x) => - this.itemHelper.getItemPrice(x[0]) < roublesBudget - ).map((x) => x[1]); - } - - return rewardableItemPoolWithinBudget; - } - - /** - * Helper to create a reward item structured as required by the client - * - * @param {string} tpl ItemId of the rewarded item - * @param {integer} value Amount of items to give - * @param {integer} index All rewards will be appended to a list, for unknown reasons the client wants the index - * @returns {object} Object of "Reward"-item-type - */ - protected generateRewardItem(tpl: string, value: number, index: number, preset: Item[] = null): IQuestReward - { - const id = this.objectId.generate(); - const rewardItem: IQuestReward = { target: id, value: value, type: QuestRewardType.ITEM, index: index }; - - if (preset) - { - const rootItem = preset.find((x) => x._tpl === tpl); - rewardItem.items = this.itemHelper.reparentItemAndChildren(rootItem, preset); - rewardItem.target = rootItem._id; // Target property and root items id must match - } - else - { - const rootItem = { _id: id, _tpl: tpl, upd: { StackObjectsCount: value, SpawnedInSession: true } }; - rewardItem.items = [rootItem]; - } - return rewardItem; - } - - /** - * Picks rewardable items from items.json. This means they need to fit into the inventory and they shouldn't be keys (debatable) - * @param repeatableQuestConfig Config file - * @returns List of rewardable items [[_tpl, itemTemplate],...] - */ - protected getRewardableItems( - repeatableQuestConfig: IRepeatableQuestConfig, - traderId: string, - ): [string, ITemplateItem][] - { - // Get an array of seasonal items that should not be shown right now as seasonal event is not active - const seasonalItems = this.seasonalEventService.getInactiveSeasonalEventItems(); - - // check for specific baseclasses which don't make sense as reward item - // also check if the price is greater than 0; there are some items whose price can not be found - // those are not in the game yet (e.g. AGS grenade launcher) - return Object.entries(this.databaseServer.getTables().templates.items).filter( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ([tpl, itemTemplate]) => - { - // Base "Item" item has no parent, ignore it - if (itemTemplate._parent === "") - { - return false; - } - - if (seasonalItems.includes(tpl)) - { - return false; - } - - const traderWhitelist = repeatableQuestConfig.traderWhitelist.find((trader) => - trader.traderId === traderId - ); - return this.isValidRewardItem(tpl, repeatableQuestConfig, traderWhitelist?.rewardBaseWhitelist); - }, - ); - } - - /** - * Checks if an id is a valid item. Valid meaning that it's an item that may be a reward - * or content of bot loot. Items that are tested as valid may be in a player backpack or stash. - * @param {string} tpl template id of item to check - * @returns True if item is valid reward - */ - protected isValidRewardItem( - tpl: string, - repeatableQuestConfig: IRepeatableQuestConfig, - itemBaseWhitelist: string[], - ): boolean - { - if (!this.itemHelper.isValidItem(tpl)) - { - return false; - } - - // Check global blacklist - if (this.itemFilterService.isItemBlacklisted(tpl)) - { - return false; - } - - // Item is on repeatable or global blacklist - if (repeatableQuestConfig.rewardBlacklist.includes(tpl) || this.itemFilterService.isItemBlacklisted(tpl)) - { - return false; - } - - // Item has blacklisted base type - if (this.itemHelper.isOfBaseclasses(tpl, [...repeatableQuestConfig.rewardBaseTypeBlacklist])) - { - return false; - } - - // Skip boss items - if (this.itemFilterService.isBossItem(tpl)) - { - return false; - } - - // Trader has specific item base types they can give as rewards to player - if (itemBaseWhitelist !== undefined) - { - if (!this.itemHelper.isOfBaseclasses(tpl, [...itemBaseWhitelist])) - { - return false; - } - } - - return 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 diff --git a/project/src/generators/RepeatableQuestRewardGenerator.ts b/project/src/generators/RepeatableQuestRewardGenerator.ts new file mode 100644 index 00000000..6eb0ca36 --- /dev/null +++ b/project/src/generators/RepeatableQuestRewardGenerator.ts @@ -0,0 +1,496 @@ +import { inject, injectable } from "tsyringe"; + +import { HandbookHelper } from "@spt-aki/helpers/HandbookHelper"; +import { ItemHelper } from "@spt-aki/helpers/ItemHelper"; +import { PresetHelper } from "@spt-aki/helpers/PresetHelper"; +import { IPreset } from "@spt-aki/models/eft/common/IGlobals"; +import { Item } from "@spt-aki/models/eft/common/tables/IItem"; +import { IQuestReward, IQuestRewards } from "@spt-aki/models/eft/common/tables/IQuest"; +import { ITemplateItem } from "@spt-aki/models/eft/common/tables/ITemplateItem"; +import { BaseClasses } from "@spt-aki/models/enums/BaseClasses"; +import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes"; +import { Money } from "@spt-aki/models/enums/Money"; +import { QuestRewardType } from "@spt-aki/models/enums/QuestRewardType"; +import { Traders } from "@spt-aki/models/enums/Traders"; +import { IBaseQuestConfig, IQuestConfig, IRepeatableQuestConfig } from "@spt-aki/models/spt/config/IQuestConfig"; +import { ExhaustableArray } from "@spt-aki/models/spt/server/ExhaustableArray"; +import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; +import { ConfigServer } from "@spt-aki/servers/ConfigServer"; +import { DatabaseServer } from "@spt-aki/servers/DatabaseServer"; +import { ItemFilterService } from "@spt-aki/services/ItemFilterService"; +import { LocalisationService } from "@spt-aki/services/LocalisationService"; +import { SeasonalEventService } from "@spt-aki/services/SeasonalEventService"; +import { JsonUtil } from "@spt-aki/utils/JsonUtil"; +import { MathUtil } from "@spt-aki/utils/MathUtil"; +import { ObjectId } from "@spt-aki/utils/ObjectId"; +import { RandomUtil } from "@spt-aki/utils/RandomUtil"; + +@injectable() +export class RepeatableQuestRewardGenerator +{ + protected questConfig: IQuestConfig; + + constructor( + @inject("WinstonLogger") protected logger: ILogger, + @inject("RandomUtil") protected randomUtil: RandomUtil, + @inject("MathUtil") protected mathUtil: MathUtil, + @inject("JsonUtil") protected jsonUtil: JsonUtil, + @inject("DatabaseServer") protected databaseServer: DatabaseServer, + @inject("ItemHelper") protected itemHelper: ItemHelper, + @inject("PresetHelper") protected presetHelper: PresetHelper, + @inject("HandbookHelper") protected handbookHelper: HandbookHelper, + @inject("LocalisationService") protected localisationService: LocalisationService, + @inject("ObjectId") protected objectId: ObjectId, + @inject("ItemFilterService") protected itemFilterService: ItemFilterService, + @inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService, + @inject("ConfigServer") protected configServer: ConfigServer, + ) + { + this.questConfig = this.configServer.getConfig(ConfigTypes.QUEST); + } + + /** + * Generate the reward for a mission. A reward can consist of + * - Experience + * - Money + * - Items + * - Trader Reputation + * + * The reward is dependent on the player level as given by the wiki. The exact mapping of pmcLevel to + * experience / money / items / trader reputation can be defined in QuestConfig.js + * + * There's also a random variation of the reward the spread of which can be also defined in the config. + * + * Additionally, a scaling factor w.r.t. quest difficulty going from 0.2...1 can be used + * + * @param {integer} pmcLevel player's level + * @param {number} difficulty a reward scaling factor from 0.2 to 1 + * @param {string} traderId the trader for reputation gain (and possible in the future filtering of reward item type based on trader) + * @param {object} repeatableConfig The configuration for the repeatable kind (daily, weekly) as configured in QuestConfig for the requested quest + * @returns {object} object of "Reward"-type that can be given for a repeatable mission + */ + public generateReward( + pmcLevel: number, + difficulty: number, + traderId: string, + repeatableConfig: IRepeatableQuestConfig, + questConfig: IBaseQuestConfig, + ): IQuestRewards + { + // difficulty could go from 0.2 ... -> for lowest difficulty receive 0.2*nominal reward + const levelsConfig = repeatableConfig.rewardScaling.levels; + const roublesConfig = repeatableConfig.rewardScaling.roubles; + const xpConfig = repeatableConfig.rewardScaling.experience; + const itemsConfig = repeatableConfig.rewardScaling.items; + const rewardSpreadConfig = repeatableConfig.rewardScaling.rewardSpread; + const skillRewardChanceConfig = repeatableConfig.rewardScaling.skillRewardChance; + const skillPointRewardConfig = repeatableConfig.rewardScaling.skillPointReward; + const reputationConfig = repeatableConfig.rewardScaling.reputation; + + const effectiveDifficulty = Number.isNaN(difficulty) ? 1 : difficulty; + if (Number.isNaN(difficulty)) + { + this.logger.warning(this.localisationService.getText("repeatable-difficulty_was_nan")); + } + + // rewards are generated based on pmcLevel, difficulty and a random spread + const rewardXP = Math.floor( + effectiveDifficulty * this.mathUtil.interp1(pmcLevel, levelsConfig, xpConfig) + * this.randomUtil.getFloat(1 - rewardSpreadConfig, 1 + rewardSpreadConfig), + ); + const rewardRoubles = Math.floor( + effectiveDifficulty * this.mathUtil.interp1(pmcLevel, levelsConfig, roublesConfig) + * this.randomUtil.getFloat(1 - rewardSpreadConfig, 1 + rewardSpreadConfig), + ); + const rewardNumItems = this.randomUtil.randInt( + 1, + Math.round(this.mathUtil.interp1(pmcLevel, levelsConfig, itemsConfig)) + 1, + ); + const rewardReputation = + Math.round( + 100 * effectiveDifficulty * this.mathUtil.interp1(pmcLevel, levelsConfig, reputationConfig) + * this.randomUtil.getFloat(1 - rewardSpreadConfig, 1 + rewardSpreadConfig), + ) / 100; + const skillRewardChance = this.mathUtil.interp1(pmcLevel, levelsConfig, skillRewardChanceConfig); + const skillPointReward = this.mathUtil.interp1(pmcLevel, levelsConfig, skillPointRewardConfig); + + // Possible improvement -> draw trader-specific items e.g. with this.itemHelper.isOfBaseclass(val._id, ItemHelper.BASECLASS.FoodDrink) + let roublesBudget = rewardRoubles; + let rewardItemPool = this.chooseRewardItemsWithinBudget(repeatableConfig, roublesBudget, traderId); + + const rewards: IQuestRewards = { Started: [], Success: [], Fail: [] }; + + let rewardIndex = 0; + // Add xp reward + if (rewardXP > 0) + { + rewards.Success.push({ value: rewardXP, type: QuestRewardType.EXPERIENCE, index: rewardIndex }); + rewardIndex++; + } + + // Add money reward + this.addMoneyReward(traderId, rewards, rewardRoubles, rewardIndex); + rewardIndex++; + + const traderWhitelistDetails = repeatableConfig.traderWhitelist.find((x) => x.traderId === traderId); + if ( + traderWhitelistDetails.rewardCanBeWeapon + && this.randomUtil.getChance100(traderWhitelistDetails.weaponRewardChancePercent) + ) + { + // Add a random default preset weapon as reward + const defaultPresetPool = new ExhaustableArray( + Object.values(this.presetHelper.getDefaultWeaponPresets()), + this.randomUtil, + this.jsonUtil, + ); + let chosenPreset: IPreset; + while (defaultPresetPool.hasValues()) + { + const randomPreset = defaultPresetPool.getRandomValue(); + const tpls = randomPreset._items.map((item) => item._tpl); + const presetPrice = this.itemHelper.getItemAndChildrenPrice(tpls); + if (presetPrice <= roublesBudget) + { + chosenPreset = this.jsonUtil.clone(randomPreset); + break; + } + } + + if (chosenPreset) + { + // use _encyclopedia as its always the base items _tpl, items[0] isn't guaranteed to be base item + rewards.Success.push( + this.generateRewardItem(chosenPreset._encyclopedia, 1, rewardIndex, chosenPreset._items), + ); + rewardIndex++; + } + } + + if (rewardItemPool.length > 0) + { + for (let i = 0; i < rewardNumItems; i++) + { + let rewardItemStackCount = 1; + const itemSelected = rewardItemPool[this.randomUtil.randInt(rewardItemPool.length)]; + + if (this.itemHelper.isOfBaseclass(itemSelected._id, BaseClasses.AMMO)) + { + // Don't reward ammo that stacks to less than what's defined in config + if (itemSelected._props.StackMaxSize < repeatableConfig.rewardAmmoStackMinSize) + { + continue; + } + + // Choose smallest value between budget fitting size and stack max + rewardItemStackCount = this.calculateAmmoStackSizeThatFitsBudget( + itemSelected, + roublesBudget, + rewardNumItems, + ); + } + + // 25% chance to double, triple quadruple reward stack (Only occurs when item is stackable and not weapon, armor or ammo) + if (this.canIncreaseRewardItemStackSize(itemSelected, 70000)) + { + rewardItemStackCount = this.getRandomisedRewardItemStackSizeByPrice(itemSelected); + } + + rewards.Success.push(this.generateRewardItem(itemSelected._id, rewardItemStackCount, rewardIndex)); + rewardIndex++; + + const itemCost = this.itemHelper.getStaticItemPrice(itemSelected._id); + roublesBudget -= rewardItemStackCount * itemCost; + + // If we still have budget narrow down possible items + if (roublesBudget > 0) + { + // Filter possible reward items to only items with a price below the remaining budget + rewardItemPool = rewardItemPool.filter((x) => + this.itemHelper.getStaticItemPrice(x._id) < roublesBudget + ); + if (rewardItemPool.length === 0) + { + break; // No reward items left, exit + } + } + else + { + break; + } + } + } + + // Add rep reward to rewards array + if (rewardReputation > 0) + { + const reward: IQuestReward = { + target: traderId, + value: rewardReputation, + type: QuestRewardType.TRADER_STANDING, + index: rewardIndex, + }; + rewards.Success.push(reward); + rewardIndex++; + } + + // Chance of adding skill reward + if (this.randomUtil.getChance100(skillRewardChance * 100)) + { + const reward: IQuestReward = { + target: this.randomUtil.getArrayValue(questConfig.possibleSkillRewards), + value: skillPointReward, + type: QuestRewardType.SKILL, + index: rewardIndex, + }; + rewards.Success.push(reward); + } + + return rewards; + } + + /** + * Get a randomised number a reward items stack size should be based on its handbook price + * @param item Reward item to get stack size for + * @returns Stack size value + */ + protected getRandomisedRewardItemStackSizeByPrice(item: ITemplateItem): number + { + const rewardItemPrice = this.itemHelper.getStaticItemPrice(item._id); + if (rewardItemPrice < 3000) + { + return this.randomUtil.getArrayValue([2, 3, 4]); + } + + if (rewardItemPrice < 10000) + { + return this.randomUtil.getArrayValue([2, 3]); + } + + return 2; + } + + /** + * Should reward item have stack size increased (25% chance) + * @param item Item to possibly increase stack size of + * @param maxRoublePriceToStack Maximum rouble price an item can be to still be chosen for stacking + * @returns True if it should + */ + protected canIncreaseRewardItemStackSize(item: ITemplateItem, maxRoublePriceToStack: number): boolean + { + return this.itemHelper.getStaticItemPrice(item._id) < maxRoublePriceToStack + && !this.itemHelper.isOfBaseclasses(item._id, [ + BaseClasses.WEAPON, + BaseClasses.ARMORED_EQUIPMENT, + BaseClasses.AMMO, + ]) + && !this.itemHelper.itemRequiresSoftInserts(item._id) + && this.randomUtil.getChance100(25); + } + + protected calculateAmmoStackSizeThatFitsBudget( + itemSelected: ITemplateItem, + roublesBudget: number, + rewardNumItems: number, + ): number + { + // The budget for this ammo stack + const stackRoubleBudget = roublesBudget / rewardNumItems; + + const singleCartridgePrice = this.handbookHelper.getTemplatePrice(itemSelected._id); + + // Get a stack size of ammo that fits rouble budget + const stackSizeThatFitsBudget = Math.round(stackRoubleBudget / singleCartridgePrice); + + // Get itemDbs max stack size for ammo - don't go above 100 (some mods mess around with stack sizes) + const stackMaxCount = Math.min(itemSelected._props.StackMaxSize, 100); + + return Math.min(stackSizeThatFitsBudget, stackMaxCount); + } + + /** + * Select a number of items that have a colelctive value of the passed in parameter + * @param repeatableConfig Config + * @param roublesBudget Total value of items to return + * @returns Array of reward items that fit budget + */ + protected chooseRewardItemsWithinBudget( + repeatableConfig: IRepeatableQuestConfig, + roublesBudget: number, + traderId: string, + ): ITemplateItem[] + { + // First filter for type and baseclass to avoid lookup in handbook for non-available items + const rewardableItemPool = this.getRewardableItems(repeatableConfig, traderId); + const minPrice = Math.min(25000, 0.5 * roublesBudget); + + let rewardableItemPoolWithinBudget = rewardableItemPool.filter((item) => + { + // Get default preset if it exists + const defaultPreset = this.presetHelper.getDefaultPreset(item[0]); + + // Bundle up tpls we want price for + const tpls = defaultPreset ? defaultPreset._items.map((item) => item._tpl) : [item[0]]; + + // Get price of tpls + const itemPrice = this.itemHelper.getItemAndChildrenPrice(tpls); + + return itemPrice < roublesBudget && itemPrice > minPrice; + }).map((x) => x[1]); + + if (rewardableItemPoolWithinBudget.length === 0) + { + this.logger.warning( + this.localisationService.getText("repeatable-no_reward_item_found_in_price_range", { + minPrice: minPrice, + roublesBudget: roublesBudget, + }), + ); + // In case we don't find any items in the price range + rewardableItemPoolWithinBudget = rewardableItemPool.filter((x) => + this.itemHelper.getItemPrice(x[0]) < roublesBudget + ).map((x) => x[1]); + } + + return rewardableItemPoolWithinBudget; + } + + /** + * Helper to create a reward item structured as required by the client + * + * @param {string} tpl ItemId of the rewarded item + * @param {integer} value Amount of items to give + * @param {integer} index All rewards will be appended to a list, for unknown reasons the client wants the index + * @returns {object} Object of "Reward"-item-type + */ + protected generateRewardItem(tpl: string, value: number, index: number, preset: Item[] = null): IQuestReward + { + const id = this.objectId.generate(); + const rewardItem: IQuestReward = { target: id, value: value, type: QuestRewardType.ITEM, index: index }; + + if (preset) + { + const rootItem = preset.find((x) => x._tpl === tpl); + rewardItem.items = this.itemHelper.reparentItemAndChildren(rootItem, preset); + rewardItem.target = rootItem._id; // Target property and root items id must match + } + else + { + const rootItem = { _id: id, _tpl: tpl, upd: { StackObjectsCount: value, SpawnedInSession: true } }; + rewardItem.items = [rootItem]; + } + return rewardItem; + } + + /** + * Picks rewardable items from items.json. This means they need to fit into the inventory and they shouldn't be keys (debatable) + * @param repeatableQuestConfig Config file + * @returns List of rewardable items [[_tpl, itemTemplate],...] + */ + public getRewardableItems( + repeatableQuestConfig: IRepeatableQuestConfig, + traderId: string, + ): [string, ITemplateItem][] + { + // Get an array of seasonal items that should not be shown right now as seasonal event is not active + const seasonalItems = this.seasonalEventService.getInactiveSeasonalEventItems(); + + // check for specific baseclasses which don't make sense as reward item + // also check if the price is greater than 0; there are some items whose price can not be found + // those are not in the game yet (e.g. AGS grenade launcher) + return Object.entries(this.databaseServer.getTables().templates.items).filter( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ([tpl, itemTemplate]) => + { + // Base "Item" item has no parent, ignore it + if (itemTemplate._parent === "") + { + return false; + } + + if (seasonalItems.includes(tpl)) + { + return false; + } + + const traderWhitelist = repeatableQuestConfig.traderWhitelist.find((trader) => + trader.traderId === traderId + ); + return this.isValidRewardItem(tpl, repeatableQuestConfig, traderWhitelist?.rewardBaseWhitelist); + }, + ); + } + + /** + * Checks if an id is a valid item. Valid meaning that it's an item that may be a reward + * or content of bot loot. Items that are tested as valid may be in a player backpack or stash. + * @param {string} tpl template id of item to check + * @returns True if item is valid reward + */ + protected isValidRewardItem( + tpl: string, + repeatableQuestConfig: IRepeatableQuestConfig, + itemBaseWhitelist: string[], + ): boolean + { + if (!this.itemHelper.isValidItem(tpl)) + { + return false; + } + + // Check global blacklist + if (this.itemFilterService.isItemBlacklisted(tpl)) + { + return false; + } + + // Item is on repeatable or global blacklist + if (repeatableQuestConfig.rewardBlacklist.includes(tpl) || this.itemFilterService.isItemBlacklisted(tpl)) + { + return false; + } + + // Item has blacklisted base type + if (this.itemHelper.isOfBaseclasses(tpl, [...repeatableQuestConfig.rewardBaseTypeBlacklist])) + { + return false; + } + + // Skip boss items + if (this.itemFilterService.isBossItem(tpl)) + { + return false; + } + + // Trader has specific item base types they can give as rewards to player + if (itemBaseWhitelist !== undefined) + { + if (!this.itemHelper.isOfBaseclasses(tpl, [...itemBaseWhitelist])) + { + return false; + } + } + + return true; + } + + protected addMoneyReward(traderId: string, rewards: IQuestRewards, rewardRoubles: number, rewardIndex: number): void + { + // PK and Fence use euros + if (traderId === Traders.PEACEKEEPER || traderId === Traders.FENCE) + { + rewards.Success.push( + this.generateRewardItem( + Money.EUROS, + this.handbookHelper.fromRUB(rewardRoubles, Money.EUROS), + rewardIndex, + ), + ); + } + else + { + // Everyone else uses roubles + rewards.Success.push(this.generateRewardItem(Money.ROUBLES, rewardRoubles, rewardIndex)); + } + } +}