Server/project/src/generators/RepeatableQuestRewardGenerator.ts
2024-10-09 10:49:22 +01:00

645 lines
27 KiB
TypeScript

import { HandbookHelper } from "@spt/helpers/HandbookHelper";
import { ItemHelper } from "@spt/helpers/ItemHelper";
import { PresetHelper } from "@spt/helpers/PresetHelper";
import { IItem } from "@spt/models/eft/common/tables/IItem";
import { IQuestReward, IQuestRewards } from "@spt/models/eft/common/tables/IQuest";
import { ITemplateItem } from "@spt/models/eft/common/tables/ITemplateItem";
import { BaseClasses } from "@spt/models/enums/BaseClasses";
import { ConfigTypes } from "@spt/models/enums/ConfigTypes";
import { Money } from "@spt/models/enums/Money";
import { QuestRewardType } from "@spt/models/enums/QuestRewardType";
import { Traders } from "@spt/models/enums/Traders";
import {
IBaseQuestConfig,
IQuestConfig,
IRepeatableQuestConfig,
IRewardScaling,
} from "@spt/models/spt/config/IQuestConfig";
import { IQuestRewardValues } from "@spt/models/spt/repeatable/IQuestRewardValues";
import { ExhaustableArray } from "@spt/models/spt/server/ExhaustableArray";
import { ILogger } from "@spt/models/spt/utils/ILogger";
import { ConfigServer } from "@spt/servers/ConfigServer";
import { DatabaseService } from "@spt/services/DatabaseService";
import { ItemFilterService } from "@spt/services/ItemFilterService";
import { LocalisationService } from "@spt/services/LocalisationService";
import { SeasonalEventService } from "@spt/services/SeasonalEventService";
import { MathUtil } from "@spt/utils/MathUtil";
import { ObjectId } from "@spt/utils/ObjectId";
import { RandomUtil } from "@spt/utils/RandomUtil";
import { ICloner } from "@spt/utils/cloners/ICloner";
import { inject, injectable } from "tsyringe";
@injectable()
export class RepeatableQuestRewardGenerator {
protected questConfig: IQuestConfig;
constructor(
@inject("PrimaryLogger") protected logger: ILogger,
@inject("RandomUtil") protected randomUtil: RandomUtil,
@inject("MathUtil") protected mathUtil: MathUtil,
@inject("DatabaseService") protected databaseService: DatabaseService,
@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,
@inject("PrimaryCloner") protected cloner: ICloner,
) {
this.questConfig = this.configServer.getConfig(ConfigTypes.QUEST);
}
/**
* Generate the reward for a mission. A reward can consist of:
* - Experience
* - Money
* - GP coins
* - Weapon preset
* - Items
* - Trader Reputation
* - Skill level experience
*
* 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 pmcLevel Level of player reward is being generated for
* @param difficulty Reward scaling factor from 0.2 to 1
* @param traderId Trader reward will be given by
* @param repeatableConfig Config for quest type (daily, weekly)
* @param questConfig
* @param rewardTplBlacklist OPTIONAL: list of tpls to NOT use when picking a reward
* @returns IQuestRewards
*/
public generateReward(
pmcLevel: number,
difficulty: number,
traderId: string,
repeatableConfig: IRepeatableQuestConfig,
questConfig: IBaseQuestConfig,
rewardTplBlacklist?: string[],
): IQuestRewards {
// Get vars to configure rewards with
const rewardParams = this.getQuestRewardValues(repeatableConfig.rewardScaling, difficulty, pmcLevel);
// Get budget to spend on item rewards (copy of raw roubles given)
let itemRewardBudget = rewardParams.rewardRoubles;
// Possible improvement -> draw trader-specific items e.g. with this.itemHelper.isOfBaseclass(val._id, ItemHelper.BASECLASS.FoodDrink)
const rewards: IQuestRewards = { Started: [], Success: [], Fail: [] };
// Start reward index to keep track
let rewardIndex = 0;
// Add xp reward
if (rewardParams.rewardXP > 0) {
rewards.Success.push({
value: rewardParams.rewardXP,
type: QuestRewardType.EXPERIENCE,
index: rewardIndex,
});
rewardIndex++;
}
// Add money reward
rewards.Success.push(this.getMoneyReward(traderId, rewardParams.rewardRoubles, rewardIndex));
rewardIndex++;
// Add GP coin reward
rewards.Success.push(this.generateItemReward(Money.GP, rewardParams.gpCoinRewardCount, rewardIndex));
rewardIndex++;
// Add preset weapon to reward if checks pass
const traderWhitelistDetails = repeatableConfig.traderWhitelist.find(
(traderWhitelist) => traderWhitelist.traderId === traderId,
);
if (
traderWhitelistDetails?.rewardCanBeWeapon &&
this.randomUtil.getChance100(traderWhitelistDetails.weaponRewardChancePercent)
) {
const chosenWeapon = this.getRandomWeaponPresetWithinBudget(itemRewardBudget, rewardIndex);
if (chosenWeapon) {
rewards.Success.push(chosenWeapon.weapon);
// Subtract price of preset from item budget so we dont give player too much stuff
itemRewardBudget -= chosenWeapon.price;
rewardIndex++;
}
}
let inBudgetRewardItemPool = this.chooseRewardItemsWithinBudget(repeatableConfig, itemRewardBudget, traderId);
if (rewardTplBlacklist) {
// Filter reward pool of items from blacklist, only use if there's at least 1 item remaining
const filteredRewardItemPool = inBudgetRewardItemPool.filter((x) => !rewardTplBlacklist.includes(x._id));
if (filteredRewardItemPool.length > 0) {
inBudgetRewardItemPool = filteredRewardItemPool;
}
}
this.logger.debug(
`Generating daily quest for: ${traderId} with budget: ${itemRewardBudget} totalling: ${rewardParams.rewardNumItems} items`,
);
if (inBudgetRewardItemPool.length > 0) {
const itemsToReward = this.getRewardableItemsFromPoolWithinBudget(
inBudgetRewardItemPool,
rewardParams.rewardNumItems,
itemRewardBudget,
repeatableConfig,
);
// Add item rewards
for (const itemReward of itemsToReward) {
rewards.Success.push(this.generateItemReward(itemReward.item._id, itemReward.stackSize, rewardIndex));
rewardIndex++;
}
}
// Add rep reward to rewards array
if (rewardParams.rewardReputation > 0) {
const reward: IQuestReward = {
target: traderId,
value: rewardParams.rewardReputation,
type: QuestRewardType.TRADER_STANDING,
index: rewardIndex,
};
rewards.Success.push(reward);
rewardIndex++;
this.logger.debug(` Adding ${rewardParams.rewardReputation} trader reputation reward`);
}
// Chance of adding skill reward
if (this.randomUtil.getChance100(rewardParams.skillRewardChance * 100)) {
const targetSkill = this.randomUtil.getArrayValue(questConfig.possibleSkillRewards);
const reward: IQuestReward = {
target: targetSkill,
value: rewardParams.skillPointReward,
type: QuestRewardType.SKILL,
index: rewardIndex,
};
rewards.Success.push(reward);
this.logger.debug(` Adding ${rewardParams.skillPointReward} skill points to ${targetSkill}`);
}
return rewards;
}
protected getQuestRewardValues(
rewardScaling: IRewardScaling,
difficulty: number,
pmcLevel: number,
): IQuestRewardValues {
// difficulty could go from 0.2 ... -> for lowest difficulty receive 0.2*nominal reward
const levelsConfig = rewardScaling.levels;
const roublesConfig = rewardScaling.roubles;
const gpCoinConfig = rewardScaling.gpCoins;
const xpConfig = rewardScaling.experience;
const itemsConfig = rewardScaling.items;
const rewardSpreadConfig = rewardScaling.rewardSpread;
const skillRewardChanceConfig = rewardScaling.skillRewardChance;
const skillPointRewardConfig = rewardScaling.skillPointReward;
const reputationConfig = rewardScaling.reputation;
const effectiveDifficulty = Number.isNaN(difficulty) ? 1 : difficulty;
if (Number.isNaN(difficulty)) {
this.logger.warning(this.localisationService.getText("repeatable-difficulty_was_nan"));
}
return {
skillPointReward: this.mathUtil.interp1(pmcLevel, levelsConfig, skillPointRewardConfig),
skillRewardChance: this.mathUtil.interp1(pmcLevel, levelsConfig, skillRewardChanceConfig),
rewardReputation:
Math.round(
100 *
effectiveDifficulty *
this.mathUtil.interp1(pmcLevel, levelsConfig, reputationConfig) *
this.randomUtil.getFloat(1 - rewardSpreadConfig, 1 + rewardSpreadConfig),
) / 100,
rewardNumItems: this.randomUtil.randInt(
1,
Math.round(this.mathUtil.interp1(pmcLevel, levelsConfig, itemsConfig)) + 1,
),
rewardRoubles: Math.floor(
effectiveDifficulty *
this.mathUtil.interp1(pmcLevel, levelsConfig, roublesConfig) *
this.randomUtil.getFloat(1 - rewardSpreadConfig, 1 + rewardSpreadConfig),
),
gpCoinRewardCount: Math.ceil(
// Ceil value to ensure it never drops below 1
effectiveDifficulty *
this.mathUtil.interp1(pmcLevel, levelsConfig, gpCoinConfig) *
this.randomUtil.getFloat(1 - rewardSpreadConfig, 1 + rewardSpreadConfig),
),
rewardXP: Math.floor(
effectiveDifficulty *
this.mathUtil.interp1(pmcLevel, levelsConfig, xpConfig) *
this.randomUtil.getFloat(1 - rewardSpreadConfig, 1 + rewardSpreadConfig),
),
};
}
/**
* Get an array of items + stack size to give to player as reward that fit inside of a rouble budget
* @param itemPool All possible items to choose rewards from
* @param maxItemCount Total number of items to reward
* @param itemRewardBudget Rouble buget all item rewards must fit in
* @param repeatableConfig config for quest type
* @returns Items and stack size
*/
protected getRewardableItemsFromPoolWithinBudget(
itemPool: ITemplateItem[],
maxItemCount: number,
itemRewardBudget: number,
repeatableConfig: IRepeatableQuestConfig,
): { item: ITemplateItem; stackSize: number }[] {
const itemsToReturn: { item: ITemplateItem; stackSize: number }[] = [];
let exhausableItemPool = new ExhaustableArray(itemPool, this.randomUtil, this.cloner);
for (let i = 0; i < maxItemCount; i++) {
// Default stack size to 1
let rewardItemStackCount = 1;
// Get a random item
const chosenItemFromPool = exhausableItemPool.getRandomValue();
if (!exhausableItemPool.hasValues()) {
break;
}
// Handle edge case - ammo
if (this.itemHelper.isOfBaseclass(chosenItemFromPool._id, BaseClasses.AMMO)) {
// Don't reward ammo that stacks to less than what's allowed in config
if (chosenItemFromPool._props.StackMaxSize < repeatableConfig.rewardAmmoStackMinSize) {
i--;
continue;
}
// Choose smallest value between budget, fitting size and stack max
rewardItemStackCount = this.calculateAmmoStackSizeThatFitsBudget(
chosenItemFromPool,
itemRewardBudget,
maxItemCount,
);
}
// 25% chance to double, triple or quadruple reward stack
// (Only occurs when item is stackable and not weapon, armor or ammo)
if (this.canIncreaseRewardItemStackSize(chosenItemFromPool, 70000, 25)) {
rewardItemStackCount = this.getRandomisedRewardItemStackSizeByPrice(chosenItemFromPool);
}
itemsToReturn.push({ item: chosenItemFromPool, stackSize: rewardItemStackCount });
const itemCost = this.presetHelper.getDefaultPresetOrItemPrice(chosenItemFromPool._id);
itemRewardBudget -= rewardItemStackCount * itemCost;
this.logger.debug(`Added item: ${chosenItemFromPool._id} with price: ${rewardItemStackCount * itemCost}`);
// If we still have budget narrow down possible items
if (itemRewardBudget > 0) {
// Filter possible reward items to only items with a price below the remaining budget
exhausableItemPool = new ExhaustableArray(
this.filterRewardPoolWithinBudget(itemPool, itemRewardBudget, 0),
this.randomUtil,
this.cloner,
);
if (!exhausableItemPool.hasValues()) {
this.logger.debug(`Reward pool empty with: ${itemRewardBudget} roubles of budget remaining`);
break; // No reward items left, exit
}
}
// No budget for more items, end loop
break;
}
return itemsToReturn;
}
/**
* Choose a random Weapon preset that fits inside of a rouble amount limit
* @param roublesBudget
* @param rewardIndex
* @returns IQuestReward
*/
protected getRandomWeaponPresetWithinBudget(
roublesBudget: number,
rewardIndex: number,
): { weapon: IQuestReward; price: number } | undefined {
// Add a random default preset weapon as reward
const defaultPresetPool = new ExhaustableArray(
Object.values(this.presetHelper.getDefaultWeaponPresets()),
this.randomUtil,
this.cloner,
);
while (defaultPresetPool.hasValues()) {
const randomPreset = defaultPresetPool.getRandomValue();
if (!randomPreset) {
continue;
}
// Gather all tpls so we can get prices of them
const tpls = randomPreset._items.map((item) => item._tpl);
// Does preset items fit our budget
const presetPrice = this.itemHelper.getItemAndChildrenPrice(tpls);
if (presetPrice <= roublesBudget) {
this.logger.debug(`Added weapon: ${tpls[0]} with price: ${presetPrice}`);
const chosenPreset = this.cloner.clone(randomPreset);
return {
weapon: this.generatePresetReward(chosenPreset._encyclopedia, 1, rewardIndex, chosenPreset._items),
price: presetPrice,
};
}
}
return undefined;
}
/**
* @param rewardItems List of reward items to filter
* @param roublesBudget The budget remaining for rewards
* @param minPrice The minimum priced item to include
* @returns True if any items remain in `rewardItems`, false otherwise
*/
protected filterRewardPoolWithinBudget(
rewardItems: ITemplateItem[],
roublesBudget: number,
minPrice: number,
): ITemplateItem[] {
return rewardItems.filter((item) => {
const itemPrice = this.presetHelper.getDefaultPresetOrItemPrice(item._id);
return itemPrice < roublesBudget && itemPrice > minPrice;
});
}
/**
* 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 matching stack size for the passed in items price
*/
protected getRandomisedRewardItemStackSizeByPrice(item: ITemplateItem): number {
const rewardItemPrice = this.presetHelper.getDefaultPresetOrItemPrice(item._id);
// Define price tiers and corresponding stack size options
const priceTiers: {
priceThreshold: number;
stackSizes: number[];
}[] = [
{ priceThreshold: 3000, stackSizes: [2, 3, 4] },
{ priceThreshold: 10000, stackSizes: [2, 3] },
{ priceThreshold: Number.POSITIVE_INFINITY, stackSizes: [2] }, // Default for prices 10001+ RUB
];
// Find the appropriate price tier and return a random stack size from its options
const tier = priceTiers.find((tier) => rewardItemPrice < tier.priceThreshold);
return this.randomUtil.getArrayValue(tier?.stackSizes || [2]); // Default to 2 if no tier matches
}
/**
* Should reward item have stack size increased (25% chance)
* @param item Item to increase reward stack size of
* @param maxRoublePriceToStack Maximum rouble price an item can be to still be chosen for stacking
* @param randomChanceToPass Additional randomised chance of passing
* @returns True if items stack size can be increased
*/
protected canIncreaseRewardItemStackSize(
item: ITemplateItem,
maxRoublePriceToStack: number,
randomChanceToPass?: number,
): boolean {
const isEligibleForStackSizeIncrease =
this.presetHelper.getDefaultPresetOrItemPrice(item._id) < maxRoublePriceToStack &&
!this.itemHelper.isOfBaseclasses(item._id, [
BaseClasses.WEAPON,
BaseClasses.ARMORED_EQUIPMENT,
BaseClasses.AMMO,
]) &&
!this.itemHelper.itemRequiresSoftInserts(item._id);
return isEligibleForStackSizeIncrease && this.randomUtil.getChance100(randomChanceToPass ?? 100);
}
/**
* Get a count of cartridges that fits the rouble budget amount provided
* e.g. how many M80s for 50,000 roubles
* @param itemSelected Cartridge
* @param roublesBudget Rouble budget
* @param rewardNumItems
* @returns Count that fits budget (min 1)
*/
protected calculateAmmoStackSizeThatFitsBudget(
itemSelected: ITemplateItem,
roublesBudget: number,
rewardNumItems: number,
): number {
// Calculate budget per reward item
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);
// Ensure stack size is at least 1 + is no larger than the max possible stack size
return Math.max(1, 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
* @param traderId Id of the trader who will give player reward
* @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 = this.filterRewardPoolWithinBudget(
rewardableItemPool.map((item) => item[1]),
roublesBudget,
minPrice,
);
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} count Amount of items to give
* @param {integer} index All rewards will be appended to a list, for unknown reasons the client wants the index
* @param preset Optional array of preset items
* @returns {object} Object of "Reward"-item-type
*/
protected generateItemReward(tpl: string, count: number, index: number): IQuestReward {
const id = this.objectId.generate();
const questRewardItem: IQuestReward = {
target: id,
value: count,
type: QuestRewardType.ITEM,
index: index,
items: [],
};
const rootItem = { _id: id, _tpl: tpl, upd: { StackObjectsCount: count, SpawnedInSession: true } };
questRewardItem.items = [rootItem];
return questRewardItem;
}
/**
* Helper to create a reward item structured as required by the client
*
* @param {string} tpl ItemId of the rewarded item
* @param {integer} count Amount of items to give
* @param {integer} index All rewards will be appended to a list, for unknown reasons the client wants the index
* @param preset Optional array of preset items
* @returns {object} Object of "Reward"-item-type
*/
protected generatePresetReward(tpl: string, count: number, index: number, preset?: IItem[]): IQuestReward {
const id = this.objectId.generate();
const questRewardItem: IQuestReward = {
target: id,
value: count,
type: QuestRewardType.ITEM,
index: index,
items: [],
};
// Get presets root item
const rootItem = preset.find((item) => item._tpl === tpl);
if (!rootItem) {
this.logger.warning(`Root item of preset: ${tpl} not found`);
}
questRewardItem.items = this.itemHelper.reparentItemAndChildren(rootItem, preset);
questRewardItem.target = rootItem._id; // Target property and root items id must match
return questRewardItem;
}
/**
* Picks rewardable items from items.json
* This means they must:
* - Fit into the inventory
* - Shouldn't be keys
* - Have a price greater than 0
* @param repeatableQuestConfig Config file
* @param traderId Id of trader who will give reward to player
* @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.databaseService.getItems()).filter(([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 {
// Return early if not valid item to give as reward
if (!this.itemHelper.isValidItem(tpl)) {
return false;
}
// Check item is not blacklisted
if (
this.itemFilterService.isItemBlacklisted(tpl) ||
this.itemFilterService.isItemRewardBlacklisted(tpl) ||
repeatableQuestConfig.rewardBlacklist.includes(tpl) ||
this.itemFilterService.isItemBlacklisted(tpl)
) {
return false;
}
// Item has blacklisted base types
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 && !this.itemHelper.isOfBaseclasses(tpl, [...itemBaseWhitelist])) {
return false;
}
return true;
}
protected getMoneyReward(traderId: string, rewardRoubles: number, rewardIndex: number): IQuestReward {
// Determine currency based on trader
// PK and Fence use Euros, everyone else is Roubles
const currency = traderId === Traders.PEACEKEEPER || traderId === Traders.FENCE ? Money.EUROS : Money.ROUBLES;
// Convert reward amount to Euros if necessary
const rewardAmountToGivePlayer =
currency === Money.EUROS ? this.handbookHelper.fromRUB(rewardRoubles, Money.EUROS) : rewardRoubles;
// Get chosen currency + amount and return
return this.generateItemReward(currency, rewardAmountToGivePlayer, rewardIndex);
}
}