Move reward code out of repeatable controller and into its own class
This commit is contained in:
parent
b45a091099
commit
da3b2944a7
@ -76,6 +76,7 @@ import { PlayerScavGenerator } from "@spt-aki/generators/PlayerScavGenerator";
|
|||||||
import { RagfairAssortGenerator } from "@spt-aki/generators/RagfairAssortGenerator";
|
import { RagfairAssortGenerator } from "@spt-aki/generators/RagfairAssortGenerator";
|
||||||
import { RagfairOfferGenerator } from "@spt-aki/generators/RagfairOfferGenerator";
|
import { RagfairOfferGenerator } from "@spt-aki/generators/RagfairOfferGenerator";
|
||||||
import { RepeatableQuestGenerator } from "@spt-aki/generators/RepeatableQuestGenerator";
|
import { RepeatableQuestGenerator } from "@spt-aki/generators/RepeatableQuestGenerator";
|
||||||
|
import { RepeatableQuestRewardGenerator } from "@spt-aki/generators/RepeatableQuestRewardGenerator";
|
||||||
import { ScavCaseRewardGenerator } from "@spt-aki/generators/ScavCaseRewardGenerator";
|
import { ScavCaseRewardGenerator } from "@spt-aki/generators/ScavCaseRewardGenerator";
|
||||||
import { WeatherGenerator } from "@spt-aki/generators/WeatherGenerator";
|
import { WeatherGenerator } from "@spt-aki/generators/WeatherGenerator";
|
||||||
import { BarrelInventoryMagGen } from "@spt-aki/generators/weapongen/implementations/BarrelInventoryMagGen";
|
import { BarrelInventoryMagGen } from "@spt-aki/generators/weapongen/implementations/BarrelInventoryMagGen";
|
||||||
@ -520,6 +521,9 @@ export class Container
|
|||||||
depContainer.register<RepeatableQuestGenerator>("RepeatableQuestGenerator", {
|
depContainer.register<RepeatableQuestGenerator>("RepeatableQuestGenerator", {
|
||||||
useClass: RepeatableQuestGenerator,
|
useClass: RepeatableQuestGenerator,
|
||||||
});
|
});
|
||||||
|
depContainer.register<RepeatableQuestRewardGenerator>("RepeatableQuestRewardGenerator", {
|
||||||
|
useClass: RepeatableQuestRewardGenerator,
|
||||||
|
});
|
||||||
|
|
||||||
depContainer.register<BarrelInventoryMagGen>("BarrelInventoryMagGen", { useClass: BarrelInventoryMagGen });
|
depContainer.register<BarrelInventoryMagGen>("BarrelInventoryMagGen", { useClass: BarrelInventoryMagGen });
|
||||||
depContainer.register<ExternalInventoryMagGen>("ExternalInventoryMagGen", {
|
depContainer.register<ExternalInventoryMagGen>("ExternalInventoryMagGen", {
|
||||||
|
@ -1,52 +1,31 @@
|
|||||||
import { inject, injectable } from "tsyringe";
|
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 { 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 { 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 { Exit, ILocationBase } from "@spt-aki/models/eft/common/ILocationBase";
|
||||||
import { TraderInfo } from "@spt-aki/models/eft/common/tables/IBotBase";
|
import { TraderInfo } from "@spt-aki/models/eft/common/tables/IBotBase";
|
||||||
import { Item } from "@spt-aki/models/eft/common/tables/IItem";
|
import { IQuestCondition, IQuestConditionCounterCondition } from "@spt-aki/models/eft/common/tables/IQuest";
|
||||||
import {
|
|
||||||
IQuestCondition,
|
|
||||||
IQuestConditionCounterCondition,
|
|
||||||
IQuestReward,
|
|
||||||
IQuestRewards,
|
|
||||||
} from "@spt-aki/models/eft/common/tables/IQuest";
|
|
||||||
import { IRepeatableQuest } from "@spt-aki/models/eft/common/tables/IRepeatableQuests";
|
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 { BaseClasses } from "@spt-aki/models/enums/BaseClasses";
|
||||||
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
|
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 { Traders } from "@spt-aki/models/enums/Traders";
|
||||||
import {
|
import {
|
||||||
IBaseQuestConfig,
|
|
||||||
IBossInfo,
|
IBossInfo,
|
||||||
IEliminationConfig,
|
IEliminationConfig,
|
||||||
IQuestConfig,
|
IQuestConfig,
|
||||||
IRepeatableQuestConfig,
|
IRepeatableQuestConfig,
|
||||||
} from "@spt-aki/models/spt/config/IQuestConfig";
|
} from "@spt-aki/models/spt/config/IQuestConfig";
|
||||||
import { IQuestTypePool } from "@spt-aki/models/spt/repeatable/IQuestTypePool";
|
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 { ILogger } from "@spt-aki/models/spt/utils/ILogger";
|
||||||
import { EventOutputHolder } from "@spt-aki/routers/EventOutputHolder";
|
|
||||||
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
|
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
|
||||||
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
|
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
|
||||||
import { ItemFilterService } from "@spt-aki/services/ItemFilterService";
|
import { ItemFilterService } from "@spt-aki/services/ItemFilterService";
|
||||||
import { LocalisationService } from "@spt-aki/services/LocalisationService";
|
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 { JsonUtil } from "@spt-aki/utils/JsonUtil";
|
||||||
import { MathUtil } from "@spt-aki/utils/MathUtil";
|
import { MathUtil } from "@spt-aki/utils/MathUtil";
|
||||||
import { ObjectId } from "@spt-aki/utils/ObjectId";
|
import { ObjectId } from "@spt-aki/utils/ObjectId";
|
||||||
import { ProbabilityObjectArray, RandomUtil } from "@spt-aki/utils/RandomUtil";
|
import { ProbabilityObjectArray, RandomUtil } from "@spt-aki/utils/RandomUtil";
|
||||||
import { TimeUtil } from "@spt-aki/utils/TimeUtil";
|
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class RepeatableQuestGenerator
|
export class RepeatableQuestGenerator
|
||||||
@ -54,26 +33,18 @@ export class RepeatableQuestGenerator
|
|||||||
protected questConfig: IQuestConfig;
|
protected questConfig: IQuestConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@inject("TimeUtil") protected timeUtil: TimeUtil,
|
|
||||||
@inject("WinstonLogger") protected logger: ILogger,
|
@inject("WinstonLogger") protected logger: ILogger,
|
||||||
@inject("RandomUtil") protected randomUtil: RandomUtil,
|
@inject("RandomUtil") protected randomUtil: RandomUtil,
|
||||||
@inject("HttpResponseUtil") protected httpResponse: HttpResponseUtil,
|
|
||||||
@inject("MathUtil") protected mathUtil: MathUtil,
|
@inject("MathUtil") protected mathUtil: MathUtil,
|
||||||
@inject("JsonUtil") protected jsonUtil: JsonUtil,
|
@inject("JsonUtil") protected jsonUtil: JsonUtil,
|
||||||
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
|
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
|
||||||
@inject("ItemHelper") protected itemHelper: ItemHelper,
|
@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("LocalisationService") protected localisationService: LocalisationService,
|
||||||
@inject("PaymentService") protected paymentService: PaymentService,
|
|
||||||
@inject("ObjectId") protected objectId: ObjectId,
|
@inject("ObjectId") protected objectId: ObjectId,
|
||||||
@inject("ItemFilterService") protected itemFilterService: ItemFilterService,
|
@inject("ItemFilterService") protected itemFilterService: ItemFilterService,
|
||||||
@inject("RepeatableQuestHelper") protected repeatableQuestHelper: RepeatableQuestHelper,
|
@inject("RepeatableQuestHelper") protected repeatableQuestHelper: RepeatableQuestHelper,
|
||||||
@inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService,
|
@inject("RepeatableQuestRewardGenerator") protected repeatableQuestRewardGenerator:
|
||||||
|
RepeatableQuestRewardGenerator,
|
||||||
@inject("ConfigServer") protected configServer: ConfigServer,
|
@inject("ConfigServer") protected configServer: ConfigServer,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@ -381,7 +352,7 @@ export class RepeatableQuestGenerator
|
|||||||
availableForFinishCondition.id = this.objectId.generate();
|
availableForFinishCondition.id = this.objectId.generate();
|
||||||
quest.location = this.getQuestLocationByMapId(locationKey);
|
quest.location = this.getQuestLocationByMapId(locationKey);
|
||||||
|
|
||||||
quest.rewards = this.generateReward(
|
quest.rewards = this.repeatableQuestRewardGenerator.generateReward(
|
||||||
pmcLevel,
|
pmcLevel,
|
||||||
Math.min(difficulty, 1),
|
Math.min(difficulty, 1),
|
||||||
traderId,
|
traderId,
|
||||||
@ -519,7 +490,10 @@ export class RepeatableQuestGenerator
|
|||||||
const quest = this.generateRepeatableTemplate("Completion", traderId, repeatableConfig.side);
|
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"
|
// 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
|
// Be fair, don't let the items be more expensive than the reward
|
||||||
let roublesBudget = Math.floor(
|
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;
|
return quest;
|
||||||
}
|
}
|
||||||
@ -795,7 +775,13 @@ export class RepeatableQuestGenerator
|
|||||||
// Difficulty for exploration goes from 1 extract to maxExtracts
|
// Difficulty for exploration goes from 1 extract to maxExtracts
|
||||||
// Difficulty for reward goes from 0.2...1 -> map
|
// Difficulty for reward goes from 0.2...1 -> map
|
||||||
const difficulty = this.mathUtil.mapToRange(numExtracts, 1, explorationConfig.maxExtracts, 0.2, 1);
|
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;
|
return quest;
|
||||||
}
|
}
|
||||||
@ -868,7 +854,13 @@ export class RepeatableQuestGenerator
|
|||||||
equipmentCondition.equipmentInclusive = [[itemTypeToFetchWithCount.itemType]];
|
equipmentCondition.equipmentInclusive = [[itemTypeToFetchWithCount.itemType]];
|
||||||
|
|
||||||
// Add rewards
|
// Add rewards
|
||||||
quest.rewards = this.generateReward(pmcLevel, 1, traderId, repeatableConfig, pickupConfig);
|
quest.rewards = this.repeatableQuestRewardGenerator.generateReward(
|
||||||
|
pmcLevel,
|
||||||
|
1,
|
||||||
|
traderId,
|
||||||
|
repeatableConfig,
|
||||||
|
pickupConfig,
|
||||||
|
);
|
||||||
|
|
||||||
return quest;
|
return quest;
|
||||||
}
|
}
|
||||||
@ -895,451 +887,6 @@ export class RepeatableQuestGenerator
|
|||||||
return { conditionType: "ExitName", exitName: exit.Name, id: this.objectId.generate(), dynamicLocale: true };
|
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
|
* 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
|
* The templates include Elimination, Completion and Extraction quest types
|
||||||
|
496
project/src/generators/RepeatableQuestRewardGenerator.ts
Normal file
496
project/src/generators/RepeatableQuestRewardGenerator.ts
Normal file
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user