import { HideoutHelper } from "@spt/helpers/HideoutHelper"; import { InventoryHelper } from "@spt/helpers/InventoryHelper"; import { ItemHelper } from "@spt/helpers/ItemHelper"; import { ProfileHelper } from "@spt/helpers/ProfileHelper"; import { TraderHelper } from "@spt/helpers/TraderHelper"; import { IPmcData } from "@spt/models/eft/common/IPmcData"; import { Bonus, HideoutSlot } from "@spt/models/eft/common/tables/IBotBase"; import { IPmcDataRepeatableQuest, IRepeatableQuest } from "@spt/models/eft/common/tables/IRepeatableQuests"; import { ITemplateItem } from "@spt/models/eft/common/tables/ITemplateItem"; import { StageBonus } from "@spt/models/eft/hideout/IHideoutArea"; import { IEquipmentBuild, IMagazineBuild, ISptProfile, IWeaponBuild } from "@spt/models/eft/profile/ISptProfile"; import { BonusType } from "@spt/models/enums/BonusType"; import { ConfigTypes } from "@spt/models/enums/ConfigTypes"; import { HideoutAreas } from "@spt/models/enums/HideoutAreas"; import { ICoreConfig } from "@spt/models/spt/config/ICoreConfig"; import { IRagfairConfig } from "@spt/models/spt/config/IRagfairConfig"; import { ILogger } from "@spt/models/spt/utils/ILogger"; import { ConfigServer } from "@spt/servers/ConfigServer"; import { DatabaseService } from "@spt/services/DatabaseService"; import { LocalisationService } from "@spt/services/LocalisationService"; import { HashUtil } from "@spt/utils/HashUtil"; import { JsonUtil } from "@spt/utils/JsonUtil"; import { TimeUtil } from "@spt/utils/TimeUtil"; import { Watermark } from "@spt/utils/Watermark"; import { ICloner } from "@spt/utils/cloners/ICloner"; import { inject, injectable } from "tsyringe"; @injectable() export class ProfileFixerService { protected coreConfig: ICoreConfig; protected ragfairConfig: IRagfairConfig; constructor( @inject("PrimaryLogger") protected logger: ILogger, @inject("Watermark") protected watermark: Watermark, @inject("DatabaseService") protected databaseService: DatabaseService, @inject("HideoutHelper") protected hideoutHelper: HideoutHelper, @inject("InventoryHelper") protected inventoryHelper: InventoryHelper, @inject("TraderHelper") protected traderHelper: TraderHelper, @inject("ProfileHelper") protected profileHelper: ProfileHelper, @inject("ItemHelper") protected itemHelper: ItemHelper, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("TimeUtil") protected timeUtil: TimeUtil, @inject("JsonUtil") protected jsonUtil: JsonUtil, @inject("HashUtil") protected hashUtil: HashUtil, @inject("ConfigServer") protected configServer: ConfigServer, @inject("PrimaryCloner") protected cloner: ICloner, ) { this.coreConfig = this.configServer.getConfig(ConfigTypes.CORE); this.ragfairConfig = this.configServer.getConfig(ConfigTypes.RAGFAIR); } /** * Find issues in the pmc profile data that may cause issues and fix them * @param pmcProfile profile to check and fix */ public checkForAndFixPmcProfileIssues(pmcProfile: IPmcData): void { this.removeDanglingConditionCounters(pmcProfile); this.removeDanglingTaskConditionCounters(pmcProfile); this.removeOrphanedQuests(pmcProfile); if (pmcProfile.Hideout) { this.addHideoutEliteSlots(pmcProfile); } if (pmcProfile.Skills) { this.checkForSkillsOverMaxLevel(pmcProfile); } } /** * Find issues in the scav profile data that may cause issues * @param scavProfile profile to check and fix */ public checkForAndFixScavProfileIssues(scavProfile: IPmcData): void {} /** * Attempt to fix common item issues that corrupt profiles * @param pmcProfile Profile to check items of */ public fixProfileBreakingInventoryItemIssues(pmcProfile: IPmcData): void { // Create a mapping of all inventory items, keyed by _id value const itemMapping = pmcProfile.Inventory.items.reduce((acc, curr) => { acc[curr._id] = acc[curr._id] || []; acc[curr._id].push(curr); return acc; }, {}); for (const key in itemMapping) { // Only one item for this id, not a dupe if (itemMapping[key].length === 1) { continue; } this.logger.warning(`${itemMapping[key].length - 1} duplicate(s) found for item: ${key}`); const itemAJson = this.jsonUtil.serialize(itemMapping[key][0]); const itemBJson = this.jsonUtil.serialize(itemMapping[key][1]); if (itemAJson === itemBJson) { // Both items match, we can safely delete one const indexOfItemToRemove = pmcProfile.Inventory.items.findIndex((x) => x._id === key); pmcProfile.Inventory.items.splice(indexOfItemToRemove, 1); this.logger.warning(`Deleted duplicate item: ${key}`); } else { // Items are different, replace ID with unique value // Only replace ID if items have no children, we dont want orphaned children const itemsHaveChildren = pmcProfile.Inventory.items.some((x) => x.parentId === key); if (!itemsHaveChildren) { const itemToAdjust = pmcProfile.Inventory.items.find((x) => x._id === key); itemToAdjust._id = this.hashUtil.generate(); this.logger.warning(`Replace duplicate item Id: ${key} with ${itemToAdjust._id}`); } } } // Iterate over all inventory items for (const item of pmcProfile.Inventory.items.filter((x) => x.slotId)) { if (!item.upd) { // Ignore items without a upd object continue; } // Check items with a tag that contains non alphanumeric characters const regxp = /([/w"\\'])/g; if (item.upd.Tag?.Name && regxp.test(item.upd.Tag?.Name)) { this.logger.warning(`Fixed item: ${item._id}s Tag value, removed invalid characters`); item.upd.Tag.Name = item.upd.Tag.Name.replace(regxp, ""); } // Check items with StackObjectsCount (undefined) if (item.upd?.StackObjectsCount === undefined) { this.logger.warning(`Fixed item: ${item._id}s undefined StackObjectsCount value, now set to 1`); item.upd.StackObjectsCount = 1; } } // Iterate over clothing const customizationDb = this.databaseService.getTemplates().customization; const customizationDbArray = Object.values(customizationDb); const playerIsUsec = pmcProfile.Info.Side.toLowerCase() === "usec"; // Check Head if (!customizationDb[pmcProfile.Customization.Head]) { const defaultHead = playerIsUsec ? customizationDbArray.find((x) => x._name === "DefaultUsecHead") : customizationDbArray.find((x) => x._name === "DefaultBearHead"); pmcProfile.Customization.Head = defaultHead._id; } // check Body if (!customizationDb[pmcProfile.Customization.Body]) { const defaultBody = pmcProfile.Info.Side.toLowerCase() === "usec" ? customizationDbArray.find((x) => x._name === "DefaultUsecBody") : customizationDbArray.find((x) => x._name === "DefaultBearBody"); pmcProfile.Customization.Body = defaultBody._id; } // check Hands if (!customizationDb[pmcProfile.Customization.Hands]) { const defaultHands = pmcProfile.Info.Side.toLowerCase() === "usec" ? customizationDbArray.find((x) => x._name === "DefaultUsecHands") : customizationDbArray.find((x) => x._name === "DefaultBearHands"); pmcProfile.Customization.Hands = defaultHands._id; } // check Hands if (!customizationDb[pmcProfile.Customization.Feet]) { const defaultFeet = pmcProfile.Info.Side.toLowerCase() === "usec" ? customizationDbArray.find((x) => x._name === "DefaultUsecFeet") : customizationDbArray.find((x) => x._name === "DefaultBearFeet"); pmcProfile.Customization.Feet = defaultFeet._id; } } /** * TODO - make this non-public - currently used by RepeatableQuestController * Remove unused condition counters * @param pmcProfile profile to remove old counters from */ public removeDanglingConditionCounters(pmcProfile: IPmcData): void { if (!pmcProfile.TaskConditionCounters) { return; } for (const counterId in pmcProfile.TaskConditionCounters) { const counter = pmcProfile.TaskConditionCounters[counterId]; if (!counter.sourceId) { delete pmcProfile.TaskConditionCounters[counterId]; } } } /** * Repeatable quests leave behind TaskConditionCounter objects that make the profile bloat with time, remove them * @param pmcProfile Player profile to check */ protected removeDanglingTaskConditionCounters(pmcProfile: IPmcData): void { if (pmcProfile.TaskConditionCounters) { const taskConditionKeysToRemove: string[] = []; const activeRepeatableQuests = this.getActiveRepeatableQuests(pmcProfile.RepeatableQuests); const achievements = this.databaseService.getAchievements(); // Loop over TaskConditionCounters objects and add once we want to remove to counterKeysToRemove for (const [key, taskConditionCounter] of Object.entries(pmcProfile.TaskConditionCounters)) { // Only check if profile has repeatable quests if (pmcProfile.RepeatableQuests && activeRepeatableQuests.length > 0) { const existsInActiveRepeatableQuests = activeRepeatableQuests.some( (quest) => quest._id === taskConditionCounter.sourceId, ); const existsInQuests = pmcProfile.Quests.some( (quest) => quest.qid === taskConditionCounter.sourceId, ); const isAchievementTracker = achievements.some( (quest) => quest.id === taskConditionCounter.sourceId, ); // If task conditions id is neither in activeQuests, quests or achievements - it's stale and should be cleaned up if (!(existsInActiveRepeatableQuests || existsInQuests || isAchievementTracker)) { taskConditionKeysToRemove.push(key); } } } for (const counterKeyToRemove of taskConditionKeysToRemove) { this.logger.debug(`Removed ${counterKeyToRemove} TaskConditionCounter object`); delete pmcProfile.TaskConditionCounters[counterKeyToRemove]; } } } protected getActiveRepeatableQuests(repeatableQuests: IPmcDataRepeatableQuest[]): IRepeatableQuest[] { let activeQuests: IRepeatableQuest[] = []; for (const repeatableQuest of repeatableQuests) { if (repeatableQuest.activeQuests.length > 0) { // daily/weekly collection has active quests in them, add to array and return activeQuests = activeQuests.concat(repeatableQuest.activeQuests); } } return activeQuests; } /** * After removing mods that add quests, the quest panel will break without removing these * @param pmcProfile Profile to remove dead quests from */ protected removeOrphanedQuests(pmcProfile: IPmcData): void { const quests = this.databaseService.getQuests(); const profileQuests = pmcProfile.Quests; const repeatableQuests: IRepeatableQuest[] = []; for (const repeatableQuestType of pmcProfile.RepeatableQuests) { repeatableQuests.push(...repeatableQuestType.activeQuests); } for (let i = profileQuests.length - 1; i >= 0; i--) { if (!(quests[profileQuests[i].qid] || repeatableQuests.some((x) => x._id === profileQuests[i].qid))) { profileQuests.splice(i, 1); this.logger.success("Successfully removed orphaned quest that doesnt exist in our quest data"); } } } /** * If the profile has elite Hideout Managment skill, add the additional slots from globals * NOTE: This seems redundant, but we will leave it here just incase. * @param pmcProfile profile to add slots to */ protected addHideoutEliteSlots(pmcProfile: IPmcData): void { const globals = this.databaseService.getGlobals(); const genSlots = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.GENERATOR).slots.length; const extraGenSlots = globals.config.SkillsSettings.HideoutManagement.EliteSlots.Generator.Slots; if (genSlots < 6 + extraGenSlots) { this.logger.debug("Updating generator area slots to a size of 6 + hideout management skill"); this.addEmptyObjectsToHideoutAreaSlots(HideoutAreas.GENERATOR, 6 + extraGenSlots, pmcProfile); } const waterCollSlots = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.WATER_COLLECTOR).slots .length; const extraWaterCollSlots = globals.config.SkillsSettings.HideoutManagement.EliteSlots.WaterCollector.Slots; if (waterCollSlots < 1 + extraWaterCollSlots) { this.logger.debug("Updating water collector area slots to a size of 1 + hideout management skill"); this.addEmptyObjectsToHideoutAreaSlots(HideoutAreas.WATER_COLLECTOR, 1 + extraWaterCollSlots, pmcProfile); } const filterSlots = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.AIR_FILTERING).slots.length; const extraFilterSlots = globals.config.SkillsSettings.HideoutManagement.EliteSlots.AirFilteringUnit.Slots; if (filterSlots < 3 + extraFilterSlots) { this.logger.debug("Updating air filter area slots to a size of 3 + hideout management skill"); this.addEmptyObjectsToHideoutAreaSlots(HideoutAreas.AIR_FILTERING, 3 + extraFilterSlots, pmcProfile); } const btcFarmSlots = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.BITCOIN_FARM).slots.length; const extraBtcSlots = globals.config.SkillsSettings.HideoutManagement.EliteSlots.BitcoinFarm.Slots; // BTC Farm doesnt have extra slots for hideout management, but we still check for modded stuff!! if (btcFarmSlots < 50 + extraBtcSlots) { this.logger.debug("Updating bitcoin farm area slots to a size of 50 + hideout management skill"); this.addEmptyObjectsToHideoutAreaSlots(HideoutAreas.BITCOIN_FARM, 50 + extraBtcSlots, pmcProfile); } const cultistAreaSlots = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.CIRCLE_OF_CULTISTS).slots .length; if (cultistAreaSlots < 1) { this.logger.debug("Updating cultist area slots to a size of 1"); this.addEmptyObjectsToHideoutAreaSlots(HideoutAreas.CIRCLE_OF_CULTISTS, 1, pmcProfile); } } /** * add in objects equal to the number of slots * @param areaType area to check * @param pmcProfile profile to update */ protected addEmptyObjectsToHideoutAreaSlots( areaType: HideoutAreas, emptyItemCount: number, pmcProfile: IPmcData, ): void { const area = pmcProfile.Hideout.Areas.find((x) => x.type === areaType); area.slots = this.addObjectsToArray(emptyItemCount, area.slots); } protected addObjectsToArray(count: number, slots: HideoutSlot[]): HideoutSlot[] { for (let i = 0; i < count; i++) { if (!slots.some((x) => x.locationIndex === i)) { slots.push({ locationIndex: i }); } } return slots; } /** * Check for and cap profile skills at 5100. * @param pmcProfile profile to check and fix */ protected checkForSkillsOverMaxLevel(pmcProfile: IPmcData): void { const skills = pmcProfile.Skills.Common; for (const skill of skills) { if (skill.Progress > 5100) { skill.Progress = 5100; } } } /** * Checks profile inventiory for items that do not exist inside the items db * @param sessionId Session id * @param pmcProfile Profile to check inventory of */ public checkForOrphanedModdedItems(sessionId: string, fullProfile: ISptProfile): void { const itemsDb = this.databaseService.getItems(); const pmcProfile = fullProfile.characters.pmc; // Get items placed in root of stash // TODO: extend to other areas / sub items const inventoryItemsToCheck = pmcProfile.Inventory.items.filter((item) => ["hideout", "main"].includes(item.slotId ?? ""), ); if (inventoryItemsToCheck) { // Check each item in inventory to ensure item exists in itemdb for (const item of inventoryItemsToCheck) { if (!itemsDb[item._tpl]) { this.logger.error(this.localisationService.getText("fixer-mod_item_found", item._tpl)); if (this.coreConfig.fixes.removeModItemsFromProfile) { this.logger.success( `Deleting item from inventory and insurance with id: ${item._id} tpl: ${item._tpl}`, ); // Also deletes from insured array this.inventoryHelper.removeItem(pmcProfile, item._id, sessionId); } } } } if (fullProfile.userbuilds) { // Remove invalid builds from weapon, equipment and magazine build lists const weaponBuilds = fullProfile.userbuilds?.weaponBuilds || []; fullProfile.userbuilds.weaponBuilds = weaponBuilds.filter((weaponBuild) => { return !this.shouldRemoveWeaponEquipmentBuild("weapon", weaponBuild, itemsDb); }); const equipmentBuilds = fullProfile.userbuilds?.equipmentBuilds || []; fullProfile.userbuilds.equipmentBuilds = equipmentBuilds.filter((equipmentBuild) => { return !this.shouldRemoveWeaponEquipmentBuild("equipment", equipmentBuild, itemsDb); }); const magazineBuilds = fullProfile.userbuilds?.magazineBuilds || []; fullProfile.userbuilds.magazineBuilds = magazineBuilds.filter((magazineBuild) => { return !this.shouldRemoveMagazineBuild(magazineBuild, itemsDb); }); } // Iterate over dialogs, looking for messages with items not found in item db, remove message if item found for (const dialogId in fullProfile.dialogues) { const dialog = fullProfile.dialogues[dialogId]; if (!dialog?.messages) { continue; // Skip dialog with no messages } // Iterate over all messages in dialog for (const [_, message] of Object.entries(dialog.messages)) { if (!message.items?.data) { continue; // Skip message with no items } // Fix message with no items but have the flags to indicate items to collect if (message.items.data.length === 0 && message.hasRewards) { message.hasRewards = false; message.rewardCollected = true; continue; } // Iterate over all items in message for (const item of message.items.data) { // Check item exists in itemsDb if (!itemsDb[item._tpl]) { this.logger.error(this.localisationService.getText("fixer-mod_item_found", item._tpl)); if (this.coreConfig.fixes.removeModItemsFromProfile) { dialog.messages.splice( dialog.messages.findIndex((x) => x._id === message._id), 1, ); this.logger.warning( `Item: ${item._tpl} has resulted in the deletion of message: ${message._id} from dialog ${dialogId}`, ); } break; } } } } const clothing = this.databaseService.getTemplates().customization; for (const [_, suitId] of Object.entries(fullProfile.suits)) { if (!clothing[suitId]) { this.logger.error(this.localisationService.getText("fixer-clothing_item_found", suitId)); if (this.coreConfig.fixes.removeModItemsFromProfile) { fullProfile.suits.splice(fullProfile.suits.indexOf(suitId), 1); this.logger.warning(`Non-default suit purchase: ${suitId} removed from profile`); } } } for (const repeatable of fullProfile.characters.pmc.RepeatableQuests ?? []) { for (const [_, activeQuest] of Object.entries(repeatable.activeQuests ?? [])) { if (!this.traderHelper.traderEnumHasValue(activeQuest.traderId)) { this.logger.error(this.localisationService.getText("fixer-trader_found", activeQuest.traderId)); if (this.coreConfig.fixes.removeModItemsFromProfile) { this.logger.warning( `Non-default quest: ${activeQuest._id} from trader: ${activeQuest.traderId} removed from RepeatableQuests list in profile`, ); repeatable.activeQuests.splice( repeatable.activeQuests.findIndex((x) => x._id === activeQuest._id), 1, ); } continue; } for (const successReward of activeQuest.rewards.Success) { if (successReward.type === "Item") { for (const rewardItem of successReward.items) { if (!itemsDb[rewardItem._tpl]) { this.logger.error( this.localisationService.getText("fixer-mod_item_found", rewardItem._tpl), ); if (this.coreConfig.fixes.removeModItemsFromProfile) { this.logger.warning( `Non-default quest: ${activeQuest._id} from trader: ${activeQuest.traderId} removed from RepeatableQuests list in profile`, ); repeatable.activeQuests.splice( repeatable.activeQuests.findIndex((x) => x._id === activeQuest._id), 1, ); } } } } } } } for (const traderId in fullProfile.traderPurchases) { if (!this.traderHelper.traderEnumHasValue(traderId)) { this.logger.error(this.localisationService.getText("fixer-trader_found", traderId)); if (this.coreConfig.fixes.removeModItemsFromProfile) { this.logger.warning(`Non-default trader: ${traderId} removed from traderPurchases list in profile`); delete fullProfile.traderPurchases[traderId]; } } } } /** * @param buildType The type of build, used for logging only * @param build The build to check for invalid items * @param itemsDb The items database to use for item lookup * @returns True if the build should be removed from the build list, false otherwise */ protected shouldRemoveWeaponEquipmentBuild( buildType: string, build: IWeaponBuild | IEquipmentBuild, itemsDb: Record, ): boolean { for (const item of build.Items) { // Check item exists in itemsDb if (!itemsDb[item._tpl]) { this.logger.error(this.localisationService.getText("fixer-mod_item_found", item._tpl)); if (this.coreConfig.fixes.removeModItemsFromProfile) { this.logger.warning( `Item: ${item._tpl} has resulted in the deletion of ${buildType} build: ${build.Name}`, ); return true; } break; } } return false; } /** * @param magazineBuild The magazine build to check for validity * @param itemsDb The items database to use for item lookup * @returns True if the build should be removed from the build list, false otherwise */ protected shouldRemoveMagazineBuild( magazineBuild: IMagazineBuild, itemsDb: Record, ): boolean { for (const item of magazineBuild.Items) { // Magazine builds can have undefined items in them, skip those if (!item) { continue; } // Check item exists in itemsDb if (!itemsDb[item.TemplateId]) { this.logger.error(this.localisationService.getText("fixer-mod_item_found", item.TemplateId)); if (this.coreConfig.fixes.removeModItemsFromProfile) { this.logger.warning( `Item: ${item.TemplateId} has resulted in the deletion of magazine build: ${magazineBuild.Name}`, ); return true; } break; } } return false; } /** * REQUIRED for dev profiles * Iterate over players hideout areas and find what's build, look for missing bonuses those areas give and add them if missing * @param pmcProfile Profile to update */ public addMissingHideoutBonusesToProfile(pmcProfile: IPmcData): void { const profileHideoutAreas = pmcProfile.Hideout.Areas; const profileBonuses = pmcProfile.Bonuses; const dbHideoutAreas = this.databaseService.getHideout().areas; for (const profileArea of profileHideoutAreas) { const areaType = profileArea.type; const level = profileArea.level; if (level === 0) { continue; } // Get array of hideout area upgrade levels to check for bonuses // Zero indexed const areaLevelsToCheck: number[] = []; for (let index = 0; index < level + 1; index++) { areaLevelsToCheck.push(index); } // Iterate over area levels, check for bonuses, add if needed const dbArea = dbHideoutAreas.find((x) => x.type === areaType); if (!dbArea) { continue; } for (const level of areaLevelsToCheck) { // Get areas level bonuses from db const levelBonuses = dbArea.stages[level]?.bonuses; if (!levelBonuses || levelBonuses.length === 0) { continue; } // Iterate over each bonus for the areas level for (const bonus of levelBonuses) { // Check if profile has bonus const profileBonus = this.getBonusFromProfile(profileBonuses, bonus); if (!profileBonus) { // no bonus, add to profile this.logger.debug( `Profile has level ${level} area ${ HideoutAreas[profileArea.type] } but no bonus found, adding ${bonus.type}`, ); this.hideoutHelper.applyPlayerUpgradesBonuses(pmcProfile, bonus); } } } } } /** * @param profileBonuses bonuses from profile * @param bonus bonus to find * @returns matching bonus */ protected getBonusFromProfile(profileBonuses: Bonus[], bonus: StageBonus): Bonus | undefined { // match by id first, used by "TextBonus" bonuses if (bonus.id) { return profileBonuses.find((x) => x.id === bonus.id); } if (bonus.type === BonusType.STASH_SIZE) { return profileBonuses.find((x) => x.type === bonus.type && x.templateId === bonus.templateId); } if (bonus.type === BonusType.ADDITIONAL_SLOTS) { return profileBonuses.find( (x) => x.type === bonus.type && x.value === bonus.value && x.visible === bonus.visible, ); } return profileBonuses.find((x) => x.type === bonus.type && x.value === bonus.value); } }