0
0
mirror of https://github.com/sp-tarkov/server.git synced 2025-02-13 09:10:43 -05:00

Refactor quest and achievement reward handling to be more centralized

- Create a new RewardHelper class that contains the majority of shared reward handling
- Move a lot of methods from QuestRewardHelper into RewardHelper
- Fix a bug in `applyMoneyBoost` that could result in compounding money boost being applied across characters
- Route achievement reward handling through RewardHelper
This commit is contained in:
DrakiaXYZ 2025-01-19 22:41:17 -08:00
parent ad782e5906
commit b541c04bac
8 changed files with 427 additions and 364 deletions

View File

@ -594,9 +594,7 @@
"quest-handover_wrong_item": "Unable to hand item in for quest: {{questId}}, expected tpl: {{requiredTpl}} but handed in: {{handedInTpl}}", "quest-handover_wrong_item": "Unable to hand item in for quest: {{questId}}, expected tpl: {{requiredTpl}} but handed in: {{handedInTpl}}",
"quest-item_not_found_in_inventory": "changeItemStack() Item with _id: %s not found in inventory", "quest-item_not_found_in_inventory": "changeItemStack() Item with _id: %s not found in inventory",
"quest-no_skill_found": "Skill %s not found", "quest-no_skill_found": "Skill %s not found",
"quest-reward_type_not_handled": "Quest reward type: {{rewardType}} not handled for quest: {{questId}} name: {{questName}}",
"quest-unable_to_find_compare_condition": "Unrecognised Comparison Method: %s", "quest-unable_to_find_compare_condition": "Unrecognised Comparison Method: %s",
"quest-unable_to_find_matching_hideout_production": "Unable to find matching hideout craft unlock for quest: {{questName}}, matches found: {{matchCount}}",
"quest-unable_to_find_quest_in_db": "Quest id: {{questId}} with type: {{questType}} not found in database", "quest-unable_to_find_quest_in_db": "Quest id: {{questId}} with type: {{questType}} not found in database",
"quest-unable_to_find_quest_in_db_no_quest_rewards": "Unable to find quest: %s in db, unable to give quest rewards to player", "quest-unable_to_find_quest_in_db_no_quest_rewards": "Unable to find quest: %s in db, unable to give quest rewards to player",
"quest-unable_to_find_repeatable_to_replace": "Unable to find repeatable quest in profile to replace, skipping", "quest-unable_to_find_repeatable_to_replace": "Unable to find repeatable quest in profile to replace, skipping",
@ -651,6 +649,8 @@
"repeatable-quest_handover_failed_condition_invalid": "Quest handover error: condition not found or incorrect value. qid: {{body.qid}}, condition: {{body.conditionId}}", "repeatable-quest_handover_failed_condition_invalid": "Quest handover error: condition not found or incorrect value. qid: {{body.qid}}, condition: {{body.conditionId}}",
"repeatable-unable_to_accept_quest_see_log": "Unable to accept quest, see server log for details", "repeatable-unable_to_accept_quest_see_log": "Unable to accept quest, see server log for details",
"repeatable-unable_to_accept_quest_starting_message_not_found": "Unable to accept quest: {{questId}} cant find quest started message text with id: {{messageId}}", "repeatable-unable_to_accept_quest_starting_message_not_found": "Unable to accept quest: {{questId}} cant find quest started message text with id: {{messageId}}",
"reward-type_not_handled": "Reward type: {{rewardType}} not handled for quest/achievement: {{questId}}",
"reward-unable_to_find_matching_hideout_production": "Unable to find matching hideout craft unlock for quest/achievement: {{questId}}, matches found: {{matchCount}}",
"route_onupdate_no_response": "onUpdate: %s route doesn't report success or fail", "route_onupdate_no_response": "onUpdate: %s route doesn't report success or fail",
"scav-missing_karma_level_getting_default": "getScavKarmaLevel() failed, unable to find fence in profile.traderInfo. Defaulting to karma level 0", "scav-missing_karma_level_getting_default": "getScavKarmaLevel() failed, unable to find fence in profile.traderInfo. Defaulting to karma level 0",
"scav-missing_karma_settings": "Unable to get karma settings for level %s", "scav-missing_karma_settings": "Unable to get karma settings for level %s",

View File

@ -120,6 +120,7 @@ import { RagfairSellHelper } from "@spt/helpers/RagfairSellHelper";
import { RagfairServerHelper } from "@spt/helpers/RagfairServerHelper"; import { RagfairServerHelper } from "@spt/helpers/RagfairServerHelper";
import { RagfairSortHelper } from "@spt/helpers/RagfairSortHelper"; import { RagfairSortHelper } from "@spt/helpers/RagfairSortHelper";
import { RepairHelper } from "@spt/helpers/RepairHelper"; import { RepairHelper } from "@spt/helpers/RepairHelper";
import { RewardHelper } from "@spt/helpers/RewardHelper";
import { RepeatableQuestHelper } from "@spt/helpers/RepeatableQuestHelper"; import { RepeatableQuestHelper } from "@spt/helpers/RepeatableQuestHelper";
import { SecureContainerHelper } from "@spt/helpers/SecureContainerHelper"; import { SecureContainerHelper } from "@spt/helpers/SecureContainerHelper";
import { TradeHelper } from "@spt/helpers/TradeHelper"; import { TradeHelper } from "@spt/helpers/TradeHelper";
@ -616,6 +617,7 @@ export class Container {
depContainer.register<RagfairOfferHelper>("RagfairOfferHelper", { useClass: RagfairOfferHelper }); depContainer.register<RagfairOfferHelper>("RagfairOfferHelper", { useClass: RagfairOfferHelper });
depContainer.register<RagfairServerHelper>("RagfairServerHelper", { useClass: RagfairServerHelper }); depContainer.register<RagfairServerHelper>("RagfairServerHelper", { useClass: RagfairServerHelper });
depContainer.register<RepairHelper>("RepairHelper", { useClass: RepairHelper }); depContainer.register<RepairHelper>("RepairHelper", { useClass: RepairHelper });
depContainer.register<RewardHelper>("RewardHelper", { useClass: RewardHelper });
depContainer.register<TraderHelper>("TraderHelper", TraderHelper); depContainer.register<TraderHelper>("TraderHelper", TraderHelper);
depContainer.register<TraderAssortHelper>("TraderAssortHelper", TraderAssortHelper, { depContainer.register<TraderAssortHelper>("TraderAssortHelper", TraderAssortHelper, {
lifecycle: Lifecycle.Singleton, lifecycle: Lifecycle.Singleton,

View File

@ -547,50 +547,6 @@ export class ProfileHelper {
return pmcProfile.Info.Bans.some((ban) => ban.banType === BanType.RAGFAIR && currentTimestamp < ban.dateTime); return pmcProfile.Info.Bans.some((ban) => ban.banType === BanType.RAGFAIR && currentTimestamp < ban.dateTime);
} }
/**
* Add an achievement to player profile + check for and add any hideout customisation unlocks to profile
* @param fullProfile Profile to add achievement to
* @param achievementId Id of achievement to add
*/
public addAchievementToProfile(fullProfile: ISptProfile, achievementId: string): void {
// Add achievement id to profile with timestamp it was unlocked
fullProfile.characters.pmc.Achievements[achievementId] = this.timeUtil.getTimestamp();
// Check for any customisation unlocks
const achievementDataDb = this.databaseService
.getTemplates()
.achievements.find((achievement) => achievement.id === achievementId);
if (!achievementDataDb) {
return;
}
// Get customisation reward object from achievement db
const customizationDirectReward = achievementDataDb.rewards.find(
(reward) => reward.type === "CustomizationDirect",
);
if (!customizationDirectReward) {
return;
}
const customisationDataDb = this.databaseService
.getHideout()
.customisation.globals.find((customisation) => customisation.itemId === customizationDirectReward.target);
if (!customisationDataDb) {
this.logger.error(
`Unable to find customisation data for ${customizationDirectReward.target} in profile ${fullProfile.info.id}`,
);
return;
}
// Reward found, add to profile
fullProfile.customisationUnlocks.push({
id: customizationDirectReward.target,
source: CustomisationSource.ACHIEVEMENT,
type: customisationDataDb.type as CustomisationType,
});
}
public hasAccessToRepeatableFreeRefreshSystem(pmcProfile: IPmcData): boolean { public hasAccessToRepeatableFreeRefreshSystem(pmcProfile: IPmcData): boolean {
return [GameEditions.EDGE_OF_DARKNESS, GameEditions.UNHEARD].includes(<any>pmcProfile.Info?.GameVersion); return [GameEditions.EDGE_OF_DARKNESS, GameEditions.UNHEARD].includes(<any>pmcProfile.Info?.GameVersion);
} }

View File

@ -2,6 +2,7 @@ import { ItemHelper } from "@spt/helpers/ItemHelper";
import { ProfileHelper } from "@spt/helpers/ProfileHelper"; import { ProfileHelper } from "@spt/helpers/ProfileHelper";
import { QuestConditionHelper } from "@spt/helpers/QuestConditionHelper"; import { QuestConditionHelper } from "@spt/helpers/QuestConditionHelper";
import { QuestRewardHelper } from "@spt/helpers/QuestRewardHelper"; import { QuestRewardHelper } from "@spt/helpers/QuestRewardHelper";
import { RewardHelper } from "@spt/helpers/RewardHelper";
import { TraderHelper } from "@spt/helpers/TraderHelper"; import { TraderHelper } from "@spt/helpers/TraderHelper";
import { IPmcData } from "@spt/models/eft/common/IPmcData"; import { IPmcData } from "@spt/models/eft/common/IPmcData";
import { Common, IQuestStatus } from "@spt/models/eft/common/tables/IBotBase"; import { Common, IQuestStatus } from "@spt/models/eft/common/tables/IBotBase";
@ -46,6 +47,7 @@ export class QuestHelper {
@inject("LocaleService") protected localeService: LocaleService, @inject("LocaleService") protected localeService: LocaleService,
@inject("ProfileHelper") protected profileHelper: ProfileHelper, @inject("ProfileHelper") protected profileHelper: ProfileHelper,
@inject("QuestRewardHelper") protected questRewardHelper: QuestRewardHelper, @inject("QuestRewardHelper") protected questRewardHelper: QuestRewardHelper,
@inject("RewardHelper") protected rewardHelper: RewardHelper,
@inject("LocalisationService") protected localisationService: LocalisationService, @inject("LocalisationService") protected localisationService: LocalisationService,
@inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService, @inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService,
@inject("TraderHelper") protected traderHelper: TraderHelper, @inject("TraderHelper") protected traderHelper: TraderHelper,
@ -1060,7 +1062,7 @@ export class QuestHelper {
// Remove any reward that doesn't pass the game edition check // Remove any reward that doesn't pass the game edition check
for (const rewardType of Object.keys(quest.rewards)) { for (const rewardType of Object.keys(quest.rewards)) {
quest.rewards[rewardType] = quest.rewards[rewardType].filter((reward: IReward) => quest.rewards[rewardType] = quest.rewards[rewardType].filter((reward: IReward) =>
this.questRewardHelper.questRewardIsForGameEdition(reward, gameVersion), this.rewardHelper.rewardIsForGameEdition(reward, gameVersion),
); );
} }
} }

View File

@ -4,14 +4,11 @@ import { PresetHelper } from "@spt/helpers/PresetHelper";
import { ProfileHelper } from "@spt/helpers/ProfileHelper"; import { ProfileHelper } from "@spt/helpers/ProfileHelper";
import { TraderHelper } from "@spt/helpers/TraderHelper"; import { TraderHelper } from "@spt/helpers/TraderHelper";
import { IPmcData } from "@spt/models/eft/common/IPmcData"; import { IPmcData } from "@spt/models/eft/common/IPmcData";
import { CustomisationSource } from "@spt/models/eft/common/tables/ICustomisationStorage";
import { IItem } from "@spt/models/eft/common/tables/IItem"; import { IItem } from "@spt/models/eft/common/tables/IItem";
import { IQuest } from "@spt/models/eft/common/tables/IQuest"; import { IQuest } from "@spt/models/eft/common/tables/IQuest";
import { IReward } from "@spt/models/eft/common/tables/IReward"; import { IReward } from "@spt/models/eft/common/tables/IReward";
import { IHideoutProduction } from "@spt/models/eft/hideout/IHideoutProduction";
import { IItemEventRouterResponse } from "@spt/models/eft/itemEvent/IItemEventRouterResponse"; import { IItemEventRouterResponse } from "@spt/models/eft/itemEvent/IItemEventRouterResponse";
import { QuestStatus } from "@spt/models/enums/QuestStatus"; import { QuestStatus } from "@spt/models/enums/QuestStatus";
import { RewardType } from "@spt/models/enums/RewardType";
import { SkillTypes } from "@spt/models/enums/SkillTypes"; import { SkillTypes } from "@spt/models/enums/SkillTypes";
import type { ILogger } from "@spt/models/spt/utils/ILogger"; import type { ILogger } from "@spt/models/spt/utils/ILogger";
import { DatabaseService } from "@spt/services/DatabaseService"; import { DatabaseService } from "@spt/services/DatabaseService";
@ -19,6 +16,8 @@ import { LocalisationService } from "@spt/services/LocalisationService";
import { HashUtil } from "@spt/utils/HashUtil"; import { HashUtil } from "@spt/utils/HashUtil";
import type { ICloner } from "@spt/utils/cloners/ICloner"; import type { ICloner } from "@spt/utils/cloners/ICloner";
import { inject, injectable } from "tsyringe"; import { inject, injectable } from "tsyringe";
import { RewardHelper } from "@spt/helpers/RewardHelper";
import { CustomisationSource } from "@spt/models/eft/common/tables/ICustomisationStorage";
@injectable() @injectable()
export class QuestRewardHelper { export class QuestRewardHelper {
@ -33,6 +32,7 @@ export class QuestRewardHelper {
@inject("TraderHelper") protected traderHelper: TraderHelper, @inject("TraderHelper") protected traderHelper: TraderHelper,
@inject("PresetHelper") protected presetHelper: PresetHelper, @inject("PresetHelper") protected presetHelper: PresetHelper,
@inject("PrimaryCloner") protected cloner: ICloner, @inject("PrimaryCloner") protected cloner: ICloner,
@inject("RewardHelper") protected rewardHelper: RewardHelper,
) {} ) {}
/** /**
@ -78,99 +78,15 @@ export class QuestRewardHelper {
// e.g. 'Success' or 'AvailableForFinish' // e.g. 'Success' or 'AvailableForFinish'
const questStateAsString = QuestStatus[state]; const questStateAsString = QuestStatus[state];
const gameVersion = pmcProfile.Info.GameVersion; const rewards = <IReward[]>questDetails.rewards[questStateAsString];
for (const reward of <IReward[]>questDetails.rewards[questStateAsString]) { return this.rewardHelper.applyRewards(
// Handle quest reward availability for different game versions, notAvailableInGameEditions currently not used rewards,
if (!this.questRewardIsForGameEdition(reward, gameVersion)) { CustomisationSource.UNLOCKED_IN_GAME,
continue; fullProfile,
}
switch (reward.type) {
case RewardType.SKILL:
this.profileHelper.addSkillPointsToPlayer(
profileData, profileData,
reward.target as SkillTypes, questId,
Number(reward.value),
);
break;
case RewardType.EXPERIENCE:
this.profileHelper.addExperienceToPmc(sessionId, Number.parseInt(<string>reward.value)); // this must occur first as the output object needs to take the modified profile exp value
break;
case RewardType.TRADER_STANDING:
this.traderHelper.addStandingToTrader(
sessionId,
reward.target,
Number.parseFloat(<string>reward.value),
);
break;
case RewardType.TRADER_UNLOCK:
this.traderHelper.setTraderUnlockedState(reward.target, true, sessionId);
break;
case RewardType.ITEM:
// Handled by getQuestRewardItems() below
break;
case RewardType.ASSORTMENT_UNLOCK:
// Handled by getAssort(), locked assorts are stripped out by `assortHelper.stripLockedLoyaltyAssort()` before being sent to player
break;
case RewardType.ACHIEVEMENT:
this.profileHelper.addAchievementToProfile(fullProfile, reward.target);
break;
case RewardType.STASH_ROWS:
this.profileHelper.addStashRowsBonusToProfile(sessionId, Number.parseInt(<string>reward.value)); // Add specified stash rows from quest reward - requires client restart
break;
case RewardType.PRODUCTIONS_SCHEME:
this.findAndAddHideoutProductionIdToProfile(
pmcProfile,
reward,
questDetails,
sessionId,
questResponse, questResponse,
); );
break;
case RewardType.POCKETS:
this.profileHelper.replaceProfilePocketTpl(pmcProfile, reward.target);
break;
case RewardType.CUSTOMIZATION_DIRECT:
this.profileHelper.addHideoutCustomisationUnlock(
fullProfile,
reward,
CustomisationSource.UNLOCKED_IN_GAME,
);
break;
default:
this.logger.error(
this.localisationService.getText("quest-reward_type_not_handled", {
rewardType: reward.type,
questId: questId,
questName: questDetails.QuestName,
}),
);
break;
}
}
return this.getQuestRewardItems(questDetails, state, gameVersion);
}
/**
* Does the provided quest reward have a game version requirement to be given and does it match
* @param reward Reward to check
* @param gameVersion Version of game to check reward against
* @returns True if it has requirement, false if it doesnt pass check
*/
public questRewardIsForGameEdition(reward: IReward, gameVersion: string): boolean {
if (reward.availableInGameEditions?.length > 0 && !reward.availableInGameEditions?.includes(gameVersion)) {
// Reward has edition whitelist and game version isnt in it
return false;
}
if (reward.notAvailableInGameEditions?.length > 0 && reward.notAvailableInGameEditions?.includes(gameVersion)) {
// Reward has edition blacklist and game version is in it
return false;
}
// No whitelist/blacklist or reward isnt blacklisted/whitelisted
return true;
} }
/** /**
@ -228,7 +144,8 @@ export class QuestRewardHelper {
* @returns Updated quest * @returns Updated quest
*/ */
public applyMoneyBoost(quest: IQuest, bonusPercent: number, questStatus: QuestStatus): IQuest { public applyMoneyBoost(quest: IQuest, bonusPercent: number, questStatus: QuestStatus): IQuest {
const rewards: IReward[] = quest.rewards?.[QuestStatus[questStatus]] ?? []; const clonedQuest = this.cloner.clone(quest);
const rewards: IReward[] = clonedQuest.rewards?.[QuestStatus[questStatus]] ?? [];
const currencyRewards = rewards.filter( const currencyRewards = rewards.filter(
(reward) => reward.type === "Item" && this.paymentHelper.isMoneyTpl(reward.items[0]._tpl), (reward) => reward.type === "Item" && this.paymentHelper.isMoneyTpl(reward.items[0]._tpl),
); );
@ -240,205 +157,6 @@ export class QuestRewardHelper {
reward.value = newCurrencyAmount; reward.value = newCurrencyAmount;
} }
return quest; return clonedQuest;
}
/**
* WIP - Find hideout craft id and add to unlockedProductionRecipe array in player profile
* also update client response recipeUnlocked array with craft id
* @param pmcData Player profile
* @param craftUnlockReward Reward item from quest with craft unlock details
* @param questDetails Quest with craft unlock reward
* @param sessionID Session id
* @param response Response to send back to client
*/
protected findAndAddHideoutProductionIdToProfile(
pmcData: IPmcData,
craftUnlockReward: IReward,
questDetails: IQuest,
sessionID: string,
response: IItemEventRouterResponse,
): void {
const matchingProductions = this.getRewardProductionMatch(craftUnlockReward, questDetails);
if (matchingProductions.length !== 1) {
this.logger.error(
this.localisationService.getText("quest-unable_to_find_matching_hideout_production", {
questName: questDetails.QuestName,
matchCount: matchingProductions.length,
}),
);
return;
}
// Add above match to pmc profile + client response
const matchingCraftId = matchingProductions[0]._id;
pmcData.UnlockedInfo.unlockedProductionRecipe.push(matchingCraftId);
response.profileChanges[sessionID].recipeUnlocked[matchingCraftId] = true;
}
/**
* Find hideout craft for the specified quest reward
* @param craftUnlockReward Reward item from quest with craft unlock details
* @param questDetails Quest with craft unlock reward
* @returns Hideout craft
*/
public getRewardProductionMatch(craftUnlockReward: IReward, questDetails: IQuest): IHideoutProduction[] {
// Get hideout crafts and find those that match by areatype/required level/end product tpl - hope for just one match
const craftingRecipes = this.databaseService.getHideout().production.recipes;
// Area that will be used to craft unlocked item
const desiredHideoutAreaType = Number.parseInt(craftUnlockReward.traderId);
let matchingProductions = craftingRecipes.filter(
(prod) =>
prod.areaType === desiredHideoutAreaType &&
//prod.requirements.some((requirement) => requirement.questId === questDetails._id) && // BSG dont store the quest id in requirement any more!
prod.requirements.some((requirement) => requirement.type === "QuestComplete") &&
prod.requirements.some((requirement) => requirement.requiredLevel === craftUnlockReward.loyaltyLevel) &&
prod.endProduct === craftUnlockReward.items[0]._tpl,
);
// More/less than single match, above filtering wasn't strict enough
if (matchingProductions.length !== 1) {
// Multiple matches were found, last ditch attempt to match by questid (value we add manually to production.json via `gen:productionquests` command)
matchingProductions = matchingProductions.filter((prod) =>
prod.requirements.some((requirement) => requirement.questId === questDetails._id),
);
}
return matchingProductions;
}
/**
* Gets a flat list of reward items for the given quest at a specific state for the specified game version (e.g. Fail/Success)
* @param quest quest to get rewards for
* @param status Quest status that holds the items (Started, Success, Fail)
* @returns array of items with the correct maxStack
*/
protected getQuestRewardItems(quest: IQuest, status: QuestStatus, gameVersion: string): IItem[] {
if (!quest.rewards[QuestStatus[status]]) {
this.logger.warning(`Unable to find: ${status} reward for quest: ${quest.QuestName}`);
return [];
}
// Iterate over all rewards with the desired status, flatten out items that have a type of Item
const questRewards = quest.rewards[QuestStatus[status]].flatMap((reward: IReward) =>
reward.type === "Item" && this.questRewardIsForGameEdition(reward, gameVersion)
? this.processReward(reward)
: [],
);
return questRewards;
}
/**
* Take reward item from quest and set FiR status + fix stack sizes + fix mod Ids
* @param questReward Reward item to fix
* @returns Fixed rewards
*/
protected processReward(questReward: IReward): IItem[] {
/** item with mods to return */
let rewardItems: IItem[] = [];
let targets: IItem[] = [];
const mods: IItem[] = [];
// Is armor item that may need inserts / plates
if (questReward.items.length === 1 && this.itemHelper.armorItemCanHoldMods(questReward.items[0]._tpl)) {
// Only process items with slots
if (this.itemHelper.itemHasSlots(questReward.items[0]._tpl)) {
// Attempt to pull default preset from globals and add child items to reward (clones questReward.items)
this.generateArmorRewardChildSlots(questReward.items[0], questReward);
}
}
for (const rewardItem of questReward.items) {
this.itemHelper.addUpdObjectToItem(rewardItem);
// Reward items are granted Found in Raid status
rewardItem.upd.SpawnedInSession = true;
// Is root item, fix stacks
if (rewardItem._id === questReward.target) {
// Is base reward item
if (
rewardItem.parentId !== undefined &&
rewardItem.parentId === "hideout" && // Has parentId of hideout
rewardItem.upd !== undefined &&
rewardItem.upd.StackObjectsCount !== undefined && // Has upd with stackobject count
rewardItem.upd.StackObjectsCount > 1 // More than 1 item in stack
) {
rewardItem.upd.StackObjectsCount = 1;
}
targets = this.itemHelper.splitStack(rewardItem);
// splitStack created new ids for the new stacks. This would destroy the relation to possible children.
// Instead, we reset the id to preserve relations and generate a new id in the downstream loop, where we are also reparenting if required
for (const target of targets) {
target._id = rewardItem._id;
}
} else {
// Is child mod
if (questReward.items[0].upd.SpawnedInSession) {
// Propigate FiR status into child items
rewardItem.upd.SpawnedInSession = questReward.items[0].upd.SpawnedInSession;
}
mods.push(rewardItem);
}
}
// Add mods to the base items, fix ids
for (const target of targets) {
// This has all the original id relations since we reset the id to the original after the splitStack
const itemsClone = [this.cloner.clone(target)];
// Here we generate a new id for the root item
target._id = this.hashUtil.generate();
for (const mod of mods) {
itemsClone.push(this.cloner.clone(mod));
}
rewardItems = rewardItems.concat(this.itemHelper.reparentItemAndChildren(target, itemsClone));
}
return rewardItems;
}
/**
* Add missing mod items to a quest armor reward
* @param originalRewardRootItem Original armor reward item from IReward.items object
* @param questReward Armor reward from quest
*/
protected generateArmorRewardChildSlots(originalRewardRootItem: IItem, questReward: IReward): void {
// Look for a default preset from globals for armor
const defaultPreset = this.presetHelper.getDefaultPreset(originalRewardRootItem._tpl);
if (defaultPreset) {
// Found preset, use mods to hydrate reward item
const presetAndMods: IItem[] = this.itemHelper.replaceIDs(defaultPreset._items);
const newRootId = this.itemHelper.remapRootItemId(presetAndMods);
questReward.items = presetAndMods;
// Find root item and set its stack count
const rootItem = questReward.items.find((item) => item._id === newRootId);
// Remap target id to the new presets root id
questReward.target = rootItem._id;
// Copy over stack count otherwise reward shows as missing in client
this.itemHelper.addUpdObjectToItem(rootItem);
rootItem.upd.StackObjectsCount = originalRewardRootItem.upd.StackObjectsCount;
return;
}
this.logger.warning(
`Unable to find default preset for armor ${originalRewardRootItem._tpl}, adding mods manually`,
);
const itemDbData = this.itemHelper.getItem(originalRewardRootItem._tpl)[1];
// Hydrate reward with only 'required' mods - necessary for things like helmets otherwise you end up with nvgs/visors etc
questReward.items = this.itemHelper.addChildSlotItems(questReward.items, itemDbData, undefined, true);
} }
} }

View File

@ -0,0 +1,375 @@
import { ItemHelper } from "@spt/helpers/ItemHelper";
import { PresetHelper } from "@spt/helpers/PresetHelper";
import { ProfileHelper } from "@spt/helpers/ProfileHelper";
import { TraderHelper } from "@spt/helpers/TraderHelper";
import { IPmcData } from "@spt/models/eft/common/IPmcData";
import { CustomisationSource } from "@spt/models/eft/common/tables/ICustomisationStorage";
import { IItem } from "@spt/models/eft/common/tables/IItem";
import { IReward } from "@spt/models/eft/common/tables/IReward";
import { IHideoutProduction } from "@spt/models/eft/hideout/IHideoutProduction";
import { IItemEventRouterResponse } from "@spt/models/eft/itemEvent/IItemEventRouterResponse";
import { ISptProfile } from "@spt/models/eft/profile/ISptProfile";
import { RewardType } from "@spt/models/enums/RewardType";
import { SkillTypes } from "@spt/models/enums/SkillTypes";
import type { ILogger } from "@spt/models/spt/utils/ILogger";
import { DatabaseService } from "@spt/services/DatabaseService";
import { LocalisationService } from "@spt/services/LocalisationService";
import { HashUtil } from "@spt/utils/HashUtil";
import { TimeUtil } from "@spt/utils/TimeUtil";
import type { ICloner } from "@spt/utils/cloners/ICloner";
import { inject, injectable } from "tsyringe";
@injectable()
export class RewardHelper {
constructor(
@inject("PrimaryLogger") protected logger: ILogger,
@inject("HashUtil") protected hashUtil: HashUtil,
@inject("TimeUtil") protected timeUtil: TimeUtil,
@inject("ItemHelper") protected itemHelper: ItemHelper,
@inject("DatabaseService") protected databaseService: DatabaseService,
@inject("ProfileHelper") protected profileHelper: ProfileHelper,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("TraderHelper") protected traderHelper: TraderHelper,
@inject("PresetHelper") protected presetHelper: PresetHelper,
@inject("PrimaryCloner") protected cloner: ICloner,
) {}
/**
* Apply the given rewards to the passed in profile
* @param rewards List of rewards to apply
* @param source The source of the rewards (Achievement, quest)
* @param fullProfile The full profile to apply the rewards to
* @param questId The quest or achievement ID, used for finding production unlocks
* @param questResponse Response to quest completion when a production is unlocked
* @returns List of items that were rewarded
*/
public applyRewards(
rewards: IReward[],
source: CustomisationSource,
fullProfile: ISptProfile,
profileData: IPmcData,
questId: string,
questResponse?: IItemEventRouterResponse,
): IItem[] {
const sessionId = fullProfile?.info?.id;
const pmcProfile = fullProfile?.characters.pmc;
if (!pmcProfile) {
this.logger.error(`Unable to get pmc profile for: ${sessionId}, no rewards given`);
return [];
}
const gameVersion = pmcProfile.Info.GameVersion;
for (const reward of rewards) {
// Handle reward availability for different game versions, notAvailableInGameEditions currently not used
if (!this.rewardIsForGameEdition(reward, gameVersion)) {
continue;
}
switch (reward.type) {
case RewardType.SKILL:
// This needs to use the passed in profileData, as it could be the scav profile
this.profileHelper.addSkillPointsToPlayer(
profileData,
reward.target as SkillTypes,
Number(reward.value),
);
break;
case RewardType.EXPERIENCE:
this.profileHelper.addExperienceToPmc(sessionId, Number.parseInt(<string>reward.value)); // this must occur first as the output object needs to take the modified profile exp value
break;
case RewardType.TRADER_STANDING:
this.traderHelper.addStandingToTrader(
sessionId,
reward.target,
Number.parseFloat(<string>reward.value),
);
break;
case RewardType.TRADER_UNLOCK:
this.traderHelper.setTraderUnlockedState(reward.target, true, sessionId);
break;
case RewardType.ITEM:
// Item rewards are retrieved by getRewardItems() below, and returned to be handled by caller
break;
case RewardType.ASSORTMENT_UNLOCK:
// Handled by getAssort(), locked assorts are stripped out by `assortHelper.stripLockedLoyaltyAssort()` before being sent to player
break;
case RewardType.ACHIEVEMENT:
this.addAchievementToProfile(fullProfile, reward.target);
break;
case RewardType.STASH_ROWS:
this.profileHelper.addStashRowsBonusToProfile(sessionId, Number.parseInt(<string>reward.value)); // Add specified stash rows from reward - requires client restart
break;
case RewardType.PRODUCTIONS_SCHEME:
this.findAndAddHideoutProductionIdToProfile(pmcProfile, reward, questId, sessionId, questResponse);
break;
case RewardType.POCKETS:
this.profileHelper.replaceProfilePocketTpl(pmcProfile, reward.target);
break;
case RewardType.CUSTOMIZATION_DIRECT:
this.profileHelper.addHideoutCustomisationUnlock(fullProfile, reward, source);
break;
default:
this.logger.error(
this.localisationService.getText("reward-type_not_handled", {
rewardType: reward.type,
questId: questId,
}),
);
break;
}
}
return this.getRewardItems(rewards, gameVersion);
}
/**
* Does the provided reward have a game version requirement to be given and does it match
* @param reward Reward to check
* @param gameVersion Version of game to check reward against
* @returns True if it has requirement, false if it doesnt pass check
*/
public rewardIsForGameEdition(reward: IReward, gameVersion: string): boolean {
if (reward.availableInGameEditions?.length > 0 && !reward.availableInGameEditions?.includes(gameVersion)) {
// Reward has edition whitelist and game version isnt in it
return false;
}
if (reward.notAvailableInGameEditions?.length > 0 && reward.notAvailableInGameEditions?.includes(gameVersion)) {
// Reward has edition blacklist and game version is in it
return false;
}
// No whitelist/blacklist or reward isnt blacklisted/whitelisted
return true;
}
/**
* WIP - Find hideout craft id and add to unlockedProductionRecipe array in player profile
* also update client response recipeUnlocked array with craft id
* @param pmcData Player profile
* @param craftUnlockReward Reward with craft unlock details
* @param questId Quest or achievement ID with craft unlock reward
* @param sessionID Session id
* @param response Response to send back to client
*/
protected findAndAddHideoutProductionIdToProfile(
pmcData: IPmcData,
craftUnlockReward: IReward,
questId: string,
sessionID: string,
response?: IItemEventRouterResponse,
): void {
const matchingProductions = this.getRewardProductionMatch(craftUnlockReward, questId);
if (matchingProductions.length !== 1) {
this.logger.error(
this.localisationService.getText("reward-unable_to_find_matching_hideout_production", {
questId: questId,
matchCount: matchingProductions.length,
}),
);
return;
}
// Add above match to pmc profile + client response
const matchingCraftId = matchingProductions[0]._id;
pmcData.UnlockedInfo.unlockedProductionRecipe.push(matchingCraftId);
if (response) {
response.profileChanges[sessionID].recipeUnlocked[matchingCraftId] = true;
}
}
/**
* Find hideout craft for the specified reward
* @param craftUnlockReward Reward with craft unlock details
* @param questId Quest or achievement ID with craft unlock reward
* @returns Hideout craft
*/
public getRewardProductionMatch(craftUnlockReward: IReward, questId: string): IHideoutProduction[] {
// Get hideout crafts and find those that match by areatype/required level/end product tpl - hope for just one match
const craftingRecipes = this.databaseService.getHideout().production.recipes;
// Area that will be used to craft unlocked item
const desiredHideoutAreaType = Number.parseInt(craftUnlockReward.traderId);
let matchingProductions = craftingRecipes.filter(
(prod) =>
prod.areaType === desiredHideoutAreaType &&
//prod.requirements.some((requirement) => requirement.questId === questId) && // BSG dont store the quest id in requirement any more!
prod.requirements.some((requirement) => requirement.type === "QuestComplete") &&
prod.requirements.some((requirement) => requirement.requiredLevel === craftUnlockReward.loyaltyLevel) &&
prod.endProduct === craftUnlockReward.items[0]._tpl,
);
// More/less than single match, above filtering wasn't strict enough
if (matchingProductions.length !== 1) {
// Multiple matches were found, last ditch attempt to match by questid (value we add manually to production.json via `gen:productionquests` command)
matchingProductions = matchingProductions.filter((prod) =>
prod.requirements.some((requirement) => requirement.questId === questId),
);
}
return matchingProductions;
}
/**
* Gets a flat list of reward items from the given rewards for the specified game version
* @param rewards Array of rewards to get the items from
* @param gameVersion The game version of the profile
* @returns array of items with the correct maxStack
*/
protected getRewardItems(rewards: IReward[], gameVersion: string): IItem[] {
// Iterate over all rewards with the desired status, flatten out items that have a type of Item
const rewardItems = rewards.flatMap((reward: IReward) =>
reward.type === "Item" && this.rewardIsForGameEdition(reward, gameVersion)
? this.processReward(reward)
: [],
);
return rewardItems;
}
/**
* Take reward item and set FiR status + fix stack sizes + fix mod Ids
* @param reward Reward item to fix
* @returns Fixed rewards
*/
protected processReward(reward: IReward): IItem[] {
/** item with mods to return */
let rewardItems: IItem[] = [];
let targets: IItem[] = [];
const mods: IItem[] = [];
// Is armor item that may need inserts / plates
if (reward.items.length === 1 && this.itemHelper.armorItemCanHoldMods(reward.items[0]._tpl)) {
// Only process items with slots
if (this.itemHelper.itemHasSlots(reward.items[0]._tpl)) {
// Attempt to pull default preset from globals and add child items to reward (clones reward.items)
this.generateArmorRewardChildSlots(reward.items[0], reward);
}
}
for (const rewardItem of reward.items) {
this.itemHelper.addUpdObjectToItem(rewardItem);
// Reward items are granted Found in Raid status
rewardItem.upd.SpawnedInSession = true;
// Is root item, fix stacks
if (rewardItem._id === reward.target) {
// Is base reward item
if (
rewardItem.parentId !== undefined &&
rewardItem.parentId === "hideout" && // Has parentId of hideout
rewardItem.upd !== undefined &&
rewardItem.upd.StackObjectsCount !== undefined && // Has upd with stackobject count
rewardItem.upd.StackObjectsCount > 1 // More than 1 item in stack
) {
rewardItem.upd.StackObjectsCount = 1;
}
targets = this.itemHelper.splitStack(rewardItem);
// splitStack created new ids for the new stacks. This would destroy the relation to possible children.
// Instead, we reset the id to preserve relations and generate a new id in the downstream loop, where we are also reparenting if required
for (const target of targets) {
target._id = rewardItem._id;
}
} else {
// Is child mod
if (reward.items[0].upd.SpawnedInSession) {
// Propigate FiR status into child items
rewardItem.upd.SpawnedInSession = reward.items[0].upd.SpawnedInSession;
}
mods.push(rewardItem);
}
}
// Add mods to the base items, fix ids
for (const target of targets) {
// This has all the original id relations since we reset the id to the original after the splitStack
const itemsClone = [this.cloner.clone(target)];
// Here we generate a new id for the root item
target._id = this.hashUtil.generate();
for (const mod of mods) {
itemsClone.push(this.cloner.clone(mod));
}
rewardItems = rewardItems.concat(this.itemHelper.reparentItemAndChildren(target, itemsClone));
}
return rewardItems;
}
/**
* Add missing mod items to an armor reward
* @param originalRewardRootItem Original armor reward item from IReward.items object
* @param reward Armor reward
*/
protected generateArmorRewardChildSlots(originalRewardRootItem: IItem, reward: IReward): void {
// Look for a default preset from globals for armor
const defaultPreset = this.presetHelper.getDefaultPreset(originalRewardRootItem._tpl);
if (defaultPreset) {
// Found preset, use mods to hydrate reward item
const presetAndMods: IItem[] = this.itemHelper.replaceIDs(defaultPreset._items);
const newRootId = this.itemHelper.remapRootItemId(presetAndMods);
reward.items = presetAndMods;
// Find root item and set its stack count
const rootItem = reward.items.find((item) => item._id === newRootId);
// Remap target id to the new presets root id
reward.target = rootItem._id;
// Copy over stack count otherwise reward shows as missing in client
this.itemHelper.addUpdObjectToItem(rootItem);
rootItem.upd.StackObjectsCount = originalRewardRootItem.upd.StackObjectsCount;
return;
}
this.logger.warning(
`Unable to find default preset for armor ${originalRewardRootItem._tpl}, adding mods manually`,
);
const itemDbData = this.itemHelper.getItem(originalRewardRootItem._tpl)[1];
// Hydrate reward with only 'required' mods - necessary for things like helmets otherwise you end up with nvgs/visors etc
reward.items = this.itemHelper.addChildSlotItems(reward.items, itemDbData, undefined, true);
}
/**
* Add an achievement to player profile and handle any rewards for the achievement
* Triggered from a quest, or another achievement
* @param fullProfile Profile to add achievement to
* @param achievementId Id of achievement to add
*/
public addAchievementToProfile(fullProfile: ISptProfile, achievementId: string): void {
// Add achievement id to profile with timestamp it was unlocked
fullProfile.characters.pmc.Achievements[achievementId] = this.timeUtil.getTimestamp();
// Check for any customisation unlocks
const achievementDataDb = this.databaseService
.getTemplates()
.achievements.find((achievement) => achievement.id === achievementId);
if (!achievementDataDb) {
return;
}
// Note: At the moment, we don't know the exact quest and achievement data layout for an achievement
// that is triggered by a quest, that gives an item, because BSG has only done this once. However
// based on deduction, I am going to assume that the *quest* will handle the initial item reward,
// and the achievement reward should only be handled post-wipe.
// All of that is to say, we are going to ignore the list of returned reward items here
const pmcProfile = fullProfile.characters.pmc;
this.applyRewards(
achievementDataDb.rewards,
CustomisationSource.ACHIEVEMENT,
fullProfile,
pmcProfile,
achievementDataDb.id,
);
}
}

View File

@ -51,7 +51,8 @@ import { RandomUtil } from "@spt/utils/RandomUtil";
import { TimeUtil } from "@spt/utils/TimeUtil"; import { TimeUtil } from "@spt/utils/TimeUtil";
import type { ICloner } from "@spt/utils/cloners/ICloner"; import type { ICloner } from "@spt/utils/cloners/ICloner";
import { inject, injectable } from "tsyringe"; import { inject, injectable } from "tsyringe";
import { TransitionType } from "../models/enums/TransitionType"; import { TransitionType } from "@spt/models/enums/TransitionType";
import { RewardHelper } from "@spt/helpers/RewardHelper";
@injectable() @injectable()
export class LocationLifecycleService { export class LocationLifecycleService {
@ -73,6 +74,7 @@ export class LocationLifecycleService {
@inject("InRaidHelper") protected inRaidHelper: InRaidHelper, @inject("InRaidHelper") protected inRaidHelper: InRaidHelper,
@inject("HealthHelper") protected healthHelper: HealthHelper, @inject("HealthHelper") protected healthHelper: HealthHelper,
@inject("QuestHelper") protected questHelper: QuestHelper, @inject("QuestHelper") protected questHelper: QuestHelper,
@inject("RewardHelper") protected rewardHelper: RewardHelper,
@inject("MatchBotDetailsCacheService") protected matchBotDetailsCacheService: MatchBotDetailsCacheService, @inject("MatchBotDetailsCacheService") protected matchBotDetailsCacheService: MatchBotDetailsCacheService,
@inject("PmcChatResponseService") protected pmcChatResponseService: PmcChatResponseService, @inject("PmcChatResponseService") protected pmcChatResponseService: PmcChatResponseService,
@inject("PlayerScavGenerator") protected playerScavGenerator: PlayerScavGenerator, @inject("PlayerScavGenerator") protected playerScavGenerator: PlayerScavGenerator,
@ -666,7 +668,7 @@ export class LocationLifecycleService {
pmcProfile.SurvivorClass = postRaidProfile.SurvivorClass; pmcProfile.SurvivorClass = postRaidProfile.SurvivorClass;
// MUST occur prior to profile achievements being overwritten by post-raid achievements // MUST occur prior to profile achievements being overwritten by post-raid achievements
this.processAchievementCustomisationRewards(fullProfile, postRaidProfile.Achievements); this.processAchievementRewards(fullProfile, postRaidProfile.Achievements);
pmcProfile.Achievements = postRaidProfile.Achievements; pmcProfile.Achievements = postRaidProfile.Achievements;
pmcProfile.Quests = this.processPostRaidQuests(postRaidProfile.Quests); pmcProfile.Quests = this.processPostRaidQuests(postRaidProfile.Quests);
@ -730,14 +732,13 @@ export class LocationLifecycleService {
} }
/** /**
* Check for and add any customisations found via the gained achievements this raid * Check for and add any rewards found via the gained achievements this raid
* @param fullProfile Profile to add customisations to * @param fullProfile Profile to add customisations to
* @param postRaidAchievements Achievements gained this raid * @param postRaidAchievements All profile achievements at the end of the raid
*/ */
protected processAchievementCustomisationRewards( protected processAchievementRewards(fullProfile: ISptProfile, postRaidAchievements: Record<string, number>): void {
fullProfile: ISptProfile, const sessionId = fullProfile.info.id;
postRaidAchievements: Record<string, number>, const pmcProfile = fullProfile.characters.pmc;
): void {
const preRaidAchievementIds = Object.keys(fullProfile.characters.pmc.Achievements); const preRaidAchievementIds = Object.keys(fullProfile.characters.pmc.Achievements);
const postRaidAchievementIds = Object.keys(postRaidAchievements); const postRaidAchievementIds = Object.keys(postRaidAchievements);
const achievementIdsAcquiredThisRaid = postRaidAchievementIds.filter( const achievementIdsAcquiredThisRaid = postRaidAchievementIds.filter(
@ -756,14 +757,23 @@ export class LocationLifecycleService {
return; return;
} }
// Get only customisation rewards from above achievements for (const achievement of achievements) {
const customisationRewards = achievements const rewardItems = this.rewardHelper.applyRewards(
.filter((achievement) => achievement?.rewards.some((reward) => reward.type === "CustomizationDirect")) achievement.rewards,
.flatMap((achievement) => achievement?.rewards); CustomisationSource.ACHIEVEMENT,
fullProfile,
// Insert customisations into profile pmcProfile,
for (const reward of customisationRewards) { achievement.id,
this.profileHelper.addHideoutCustomisationUnlock(fullProfile, reward, CustomisationSource.ACHIEVEMENT); );
if (rewardItems?.length > 0) {
this.mailSendService.sendLocalisedSystemMessageToPlayer(
sessionId,
"670547bb5fa0b1a7c30d5836 0",
rewardItems,
[],
this.timeUtil.getHoursAsSeconds(24 * 7),
);
}
} }
} }

View File

@ -2,7 +2,7 @@ import { HideoutHelper } from "@spt/helpers/HideoutHelper";
import { InventoryHelper } from "@spt/helpers/InventoryHelper"; import { InventoryHelper } from "@spt/helpers/InventoryHelper";
import { ItemHelper } from "@spt/helpers/ItemHelper"; import { ItemHelper } from "@spt/helpers/ItemHelper";
import { ProfileHelper } from "@spt/helpers/ProfileHelper"; import { ProfileHelper } from "@spt/helpers/ProfileHelper";
import { QuestRewardHelper } from "@spt/helpers/QuestRewardHelper"; import { RewardHelper } from "@spt/helpers/RewardHelper";
import { TraderHelper } from "@spt/helpers/TraderHelper"; import { TraderHelper } from "@spt/helpers/TraderHelper";
import { IPmcData } from "@spt/models/eft/common/IPmcData"; import { IPmcData } from "@spt/models/eft/common/IPmcData";
import { IBonus, IHideoutSlot } from "@spt/models/eft/common/tables/IBotBase"; import { IBonus, IHideoutSlot } from "@spt/models/eft/common/tables/IBotBase";
@ -51,7 +51,7 @@ export class ProfileFixerService {
@inject("HashUtil") protected hashUtil: HashUtil, @inject("HashUtil") protected hashUtil: HashUtil,
@inject("ConfigServer") protected configServer: ConfigServer, @inject("ConfigServer") protected configServer: ConfigServer,
@inject("PrimaryCloner") protected cloner: ICloner, @inject("PrimaryCloner") protected cloner: ICloner,
@inject("QuestRewardHelper") protected questRewardHelper: QuestRewardHelper, @inject("RewardHelper") protected rewardHelper: RewardHelper,
) { ) {
this.coreConfig = this.configServer.getConfig(ConfigTypes.CORE); this.coreConfig = this.configServer.getConfig(ConfigTypes.CORE);
this.ragfairConfig = this.configServer.getConfig(ConfigTypes.RAGFAIR); this.ragfairConfig = this.configServer.getConfig(ConfigTypes.RAGFAIR);
@ -365,9 +365,9 @@ export class ProfileFixerService {
productionUnlockReward: IReward, productionUnlockReward: IReward,
questDetails: IQuest, questDetails: IQuest,
): void { ): void {
const matchingProductions = this.questRewardHelper.getRewardProductionMatch( const matchingProductions = this.rewardHelper.getRewardProductionMatch(
productionUnlockReward, productionUnlockReward,
questDetails, questDetails._id,
); );
if (matchingProductions.length !== 1) { if (matchingProductions.length !== 1) {
this.logger.error( this.logger.error(