0
0
mirror of https://github.com/sp-tarkov/server.git synced 2025-02-13 09:50:43 -05:00
server/project/src/services/ProfileFixerService.ts
Cj 967dc15564 ProfileFixerService Refactor (!391)
Refactor to remove legacy code that bloats the `ProfileFixerService` class. Most of which is old profile porting code, some being old profile fixes. Organizes code in the class so that public members are at the top (as they should be). Finally break out some code into their own methods so they're not in the primary method for the class `checkForAndFixPmcProfileIssues`.

I have tested this with a developer profile, an EOD profile and a Standard profile. I've encountered no issues in my own testing.

Co-authored-by: Cj <161484149+CJ-SPT@users.noreply.github.com>
Reviewed-on: SPT/Server#391
Co-authored-by: Cj <cj@noreply.dev.sp-tarkov.com>
Co-committed-by: Cj <cj@noreply.dev.sp-tarkov.com>
2024-08-08 17:09:52 +00:00

565 lines
25 KiB
TypeScript

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 { 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 { IEquipmentBuild, IMagazineBuild, ISptProfile, IWeaponBuild } from "@spt/models/eft/profile/ISptProfile";
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);
}
}
/**
* 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<string, ITemplateItem>,
): 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<string, ITemplateItem>,
): 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;
}
}