Server/project/src/services/ProfileFixerService.ts

1397 lines
54 KiB
TypeScript

import { inject, injectable } from "tsyringe";
import { HideoutHelper } from "@spt-aki/helpers/HideoutHelper";
import { InventoryHelper } from "@spt-aki/helpers/InventoryHelper";
import { ItemHelper } from "@spt-aki/helpers/ItemHelper";
import { ProfileHelper } from "@spt-aki/helpers/ProfileHelper";
import { TraderHelper } from "@spt-aki/helpers/TraderHelper";
import { IPmcData } from "@spt-aki/models/eft/common/IPmcData";
import { Bonus, HideoutSlot, IQuestStatus } from "@spt-aki/models/eft/common/tables/IBotBase";
import { IHideoutImprovement } from "@spt-aki/models/eft/common/tables/IBotBase";
import { IPmcDataRepeatableQuest, IRepeatableQuest } from "@spt-aki/models/eft/common/tables/IRepeatableQuests";
import { StageBonus } from "@spt-aki/models/eft/hideout/IHideoutArea";
import { IAkiProfile } from "@spt-aki/models/eft/profile/IAkiProfile";
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
import { HideoutAreas } from "@spt-aki/models/enums/HideoutAreas";
import { QuestStatus } from "@spt-aki/models/enums/QuestStatus";
import { Traders } from "@spt-aki/models/enums/Traders";
import { ICoreConfig } from "@spt-aki/models/spt/config/ICoreConfig";
import { IRagfairConfig } from "@spt-aki/models/spt/config/IRagfairConfig";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
import { LocalisationService } from "@spt-aki/services/LocalisationService";
import { HashUtil } from "@spt-aki/utils/HashUtil";
import { JsonUtil } from "@spt-aki/utils/JsonUtil";
import { TimeUtil } from "@spt-aki/utils/TimeUtil";
import { Watermark } from "@spt-aki/utils/Watermark";
@injectable()
export class ProfileFixerService
{
protected coreConfig: ICoreConfig;
protected ragfairConfig: IRagfairConfig;
constructor(
@inject("WinstonLogger") protected logger: ILogger,
@inject("Watermark") protected watermark: Watermark,
@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("DatabaseServer") protected databaseServer: DatabaseServer,
@inject("ConfigServer") protected configServer: ConfigServer,
)
{
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.addMissingRepeatableQuestsProperty(pmcProfile);
this.addLighthouseKeeperIfMissing(pmcProfile);
this.addUnlockedInfoObjectIfMissing(pmcProfile);
this.removeOrphanedQuests(pmcProfile);
if (pmcProfile.Inventory)
{
this.addHideoutAreaStashes(pmcProfile);
}
if (pmcProfile.Hideout)
{
this.migrateImprovements(pmcProfile);
this.addMissingBonusesProperty(pmcProfile);
this.addMissingWallImprovements(pmcProfile);
this.addMissingHideoutWallAreas(pmcProfile);
this.addMissingGunStandContainerImprovements(pmcProfile);
this.addMissingHallOfFameContainerImprovements(pmcProfile);
this.ensureGunStandLevelsMatch(pmcProfile);
this.removeResourcesFromSlotsInHideoutWithoutLocationIndexValue(pmcProfile);
this.reorderHideoutAreasWithResouceInputs(pmcProfile);
if (
pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.GENERATOR).slots.length
< (6
+ this.databaseServer.getTables().globals.config.SkillsSettings.HideoutManagement.EliteSlots
.Generator.Slots)
)
{
this.logger.debug("Updating generator area slots to a size of 6 + hideout management skill");
this.addEmptyObjectsToHideoutAreaSlots(
HideoutAreas.GENERATOR,
6
+ this.databaseServer.getTables().globals.config.SkillsSettings.HideoutManagement.EliteSlots
.Generator.Slots,
pmcProfile,
);
}
if (
pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.WATER_COLLECTOR).slots.length
< (1
+ this.databaseServer.getTables().globals.config.SkillsSettings.HideoutManagement.EliteSlots
.WaterCollector.Slots)
)
{
this.logger.debug("Updating water collector area slots to a size of 1 + hideout management skill");
this.addEmptyObjectsToHideoutAreaSlots(
HideoutAreas.WATER_COLLECTOR,
1
+ this.databaseServer.getTables().globals.config.SkillsSettings.HideoutManagement.EliteSlots
.WaterCollector.Slots,
pmcProfile,
);
}
if (
pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.AIR_FILTERING).slots.length
< (3
+ this.databaseServer.getTables().globals.config.SkillsSettings.HideoutManagement.EliteSlots
.AirFilteringUnit.Slots)
)
{
this.logger.debug("Updating air filter area slots to a size of 3 + hideout management skill");
this.addEmptyObjectsToHideoutAreaSlots(
HideoutAreas.AIR_FILTERING,
3
+ this.databaseServer.getTables().globals.config.SkillsSettings.HideoutManagement.EliteSlots
.AirFilteringUnit.Slots,
pmcProfile,
);
}
// BTC Farm doesnt have extra slots for hideout management, but we still check for modded stuff!!
if (
pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.BITCOIN_FARM).slots.length
< (50
+ this.databaseServer.getTables().globals.config.SkillsSettings.HideoutManagement.EliteSlots
.BitcoinFarm.Slots)
)
{
this.logger.debug("Updating bitcoin farm area slots to a size of 50 + hideout management skill");
this.addEmptyObjectsToHideoutAreaSlots(
HideoutAreas.BITCOIN_FARM,
50
+ this.databaseServer.getTables().globals.config.SkillsSettings.HideoutManagement.EliteSlots
.BitcoinFarm.Slots,
pmcProfile,
);
}
}
this.fixNullTraderSalesSums(pmcProfile);
this.updateProfileQuestDataValues(pmcProfile);
if (Object.keys(this.ragfairConfig.dynamic.unreasonableModPrices).length > 0)
{
this.adjustUnreasonableModFleaPrices();
}
}
/**
* Find issues in the scav profile data that may cause issues
* @param scavProfile profile to check and fix
*/
public checkForAndFixScavProfileIssues(scavProfile: IPmcData): void
{
this.updateProfileQuestDataValues(scavProfile);
}
protected addMissingGunStandContainerImprovements(pmcProfile: IPmcData): void
{
const weaponStandArea = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.WEAPON_STAND);
if (!weaponStandArea || weaponStandArea.level === 0)
{
// No stand in profile or its level 0, skip
return;
}
const db = this.databaseServer.getTables();
const hideoutStandAreaDb = db.hideout.areas.find((x) => x.type === HideoutAreas.WEAPON_STAND);
const hideoutStandSecondaryAreaDb = db.hideout.areas.find((x) => x.parentArea === hideoutStandAreaDb._id);
const stageCurrentAt = hideoutStandAreaDb.stages[weaponStandArea.level];
const hideoutStandStashId = pmcProfile.Inventory.hideoutAreaStashes[HideoutAreas.WEAPON_STAND];
const hideoutSecondaryStashId = pmcProfile.Inventory.hideoutAreaStashes[HideoutAreas.WEAPON_STAND_SECONDARY];
// `hideoutAreaStashes` empty but profile has built gun stand
if (!hideoutStandStashId && stageCurrentAt)
{
// Value is missing, add it
pmcProfile.Inventory.hideoutAreaStashes[HideoutAreas.WEAPON_STAND] = hideoutStandAreaDb._id;
pmcProfile.Inventory.hideoutAreaStashes[HideoutAreas.WEAPON_STAND_SECONDARY] =
hideoutStandSecondaryAreaDb._id;
// Add stash item to profile
const gunStandStashItem = pmcProfile.Inventory.items.find((x) => x._id === hideoutStandAreaDb._id);
if (gunStandStashItem)
{
gunStandStashItem._tpl = stageCurrentAt.container;
this.logger.debug(
`Updated existing gun stand inventory stash: ${gunStandStashItem._id} tpl to ${stageCurrentAt.container}`,
);
}
else
{
pmcProfile.Inventory.items.push({ _id: hideoutStandAreaDb._id, _tpl: stageCurrentAt.container });
this.logger.debug(
`Added missing gun stand inventory stash: ${hideoutStandAreaDb._id} tpl to ${stageCurrentAt.container}`,
);
}
// Add secondary stash item to profile
const gunStandStashSecondaryItem = pmcProfile.Inventory.items.find((x) =>
x._id === hideoutStandSecondaryAreaDb._id
);
if (gunStandStashItem)
{
gunStandStashSecondaryItem._tpl = stageCurrentAt.container;
this.logger.debug(
`Updated gun stand existing inventory secondary stash: ${gunStandStashSecondaryItem._id} tpl to ${stageCurrentAt.container}`,
);
}
else
{
pmcProfile.Inventory.items.push({
_id: hideoutStandSecondaryAreaDb._id,
_tpl: stageCurrentAt.container,
});
this.logger.debug(
`Added missing gun stand inventory secondary stash: ${hideoutStandSecondaryAreaDb._id} tpl to ${stageCurrentAt.container}`,
);
}
return;
}
let stashItem = pmcProfile.Inventory.items?.find((x) => x._id === hideoutStandAreaDb._id);
if (!stashItem)
{
// Stand inventory stash item doesnt exist, add it
pmcProfile.Inventory.items.push(
{
_id: hideoutStandAreaDb._id,
_tpl: stageCurrentAt.container
}
)
stashItem = pmcProfile.Inventory.items?.find((x) => x._id === hideoutStandAreaDb._id)
}
// `hideoutAreaStashes` has value related stash inventory items tpl doesnt match what's expected
if (hideoutStandStashId && stashItem._tpl !== stageCurrentAt.container)
{
this.logger.debug(
`primary Stash tpl was: ${stashItem._tpl}, but should be ${stageCurrentAt.container}, updating`,
);
// The id inside the profile does not match what the hideout db value is, out of sync, adjust
stashItem._tpl = stageCurrentAt.container;
}
let stashSecondaryItem = pmcProfile.Inventory.items?.find((x) => x._id === hideoutStandSecondaryAreaDb._id);
if (!stashSecondaryItem)
{
// Stand inventory stash item doesnt exist, add it
pmcProfile.Inventory.items.push(
{
_id: hideoutStandSecondaryAreaDb._id,
_tpl: stageCurrentAt.container
}
)
stashSecondaryItem = pmcProfile.Inventory.items?.find((x) => x._id === hideoutStandSecondaryAreaDb._id)
}
// `hideoutAreaStashes` has value related stash inventory items tpl doesnt match what's expected
if (hideoutSecondaryStashId && stashSecondaryItem?._tpl !== stageCurrentAt.container)
{
this.logger.debug(
`Secondary stash tpl was: ${stashSecondaryItem._tpl}, but should be ${stageCurrentAt.container}, updating`,
);
// The id inside the profile does not match what the hideout db value is, out of sync, adjust
stashSecondaryItem._tpl = stageCurrentAt.container;
}
}
protected addMissingHallOfFameContainerImprovements(pmcProfile: IPmcData): void
{
const placeOfFameArea = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.PLACE_OF_FAME);
if (!placeOfFameArea || placeOfFameArea.level === 0)
{
// No place of fame in profile or its level 0, skip
return;
}
const db = this.databaseServer.getTables();
const placeOfFameAreaDb = db.hideout.areas.find((x) => x.type === HideoutAreas.PLACE_OF_FAME);
const stageCurrentlyAt = placeOfFameAreaDb.stages[placeOfFameArea.level];
const placeOfFameStashId = pmcProfile.Inventory.hideoutAreaStashes[HideoutAreas.PLACE_OF_FAME];
// `hideoutAreaStashes` empty but profile has built gun stand
if (!placeOfFameStashId && stageCurrentlyAt)
{
// Value is missing, add it
pmcProfile.Inventory.hideoutAreaStashes[HideoutAreas.PLACE_OF_FAME] = placeOfFameAreaDb._id;
// Add stash item to profile
const placeOfFameStashItem = pmcProfile.Inventory.items.find((x) => x._id === placeOfFameAreaDb._id);
if (placeOfFameStashItem)
{
placeOfFameStashItem._tpl = stageCurrentlyAt.container;
this.logger.debug(
`Updated existing place of fame inventory stash: ${placeOfFameStashItem._id} tpl to ${stageCurrentlyAt.container}`,
);
}
else
{
pmcProfile.Inventory.items.push({ _id: placeOfFameAreaDb._id, _tpl: stageCurrentlyAt.container });
this.logger.debug(
`Added missing place of fame inventory stash: ${placeOfFameAreaDb._id} tpl to ${stageCurrentlyAt.container}`,
);
}
return;
}
let stashItem = pmcProfile.Inventory.items?.find((x) => x._id === placeOfFameAreaDb._id);
if (!stashItem)
{
// Stand inventory stash item doesnt exist, add it
pmcProfile.Inventory.items.push(
{
_id: placeOfFameAreaDb._id,
_tpl: stageCurrentlyAt.container
}
)
stashItem = pmcProfile.Inventory.items?.find((x) => x._id === placeOfFameAreaDb._id)
}
// `hideoutAreaStashes` has value related stash inventory items tpl doesnt match what's expected
if (placeOfFameStashId && stashItem._tpl !== stageCurrentlyAt.container)
{
this.logger.debug(
`primary Stash tpl was: ${stashItem._tpl}, but should be ${stageCurrentlyAt.container}, updating`,
);
// The id inside the profile does not match what the hideout db value is, out of sync, adjust
stashItem._tpl = stageCurrentlyAt.container;
}
}
protected ensureGunStandLevelsMatch(pmcProfile: IPmcData): void
{
// only proceed if stand is level 1 or above
const gunStandParent = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.WEAPON_STAND);
if (gunStandParent && gunStandParent.level > 0)
{
const gunStandChild = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.WEAPON_STAND_SECONDARY);
if (gunStandChild && gunStandParent.level !== gunStandChild.level)
{
this.logger.success("Upgraded gun stand levels to match");
gunStandChild.level = gunStandParent.level;
}
}
}
protected addHideoutAreaStashes(pmcProfile: IPmcData): void
{
if (!pmcProfile?.Inventory?.hideoutAreaStashes)
{
this.logger.debug("Added missing hideoutAreaStashes to inventory");
pmcProfile.Inventory.hideoutAreaStashes = {};
}
}
protected addMissingHideoutWallAreas(pmcProfile: IPmcData): void
{
if (!pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.WEAPON_STAND))
{
pmcProfile.Hideout.Areas.push({
type: 24,
level: 0,
active: true,
passiveBonusesEnabled: true,
completeTime: 0,
constructing: false,
slots: [],
lastRecipe: "",
});
}
if (!pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.WEAPON_STAND_SECONDARY))
{
pmcProfile.Hideout.Areas.push({
type: 25,
level: 0,
active: true,
passiveBonusesEnabled: true,
completeTime: 0,
constructing: false,
slots: [],
lastRecipe: "",
});
}
}
protected adjustUnreasonableModFleaPrices(): void
{
const db = this.databaseServer.getTables();
const fleaPrices = db.templates.prices;
const handbookPrices = db.templates.handbook.Items;
for (const itemTypeKey in this.ragfairConfig.dynamic.unreasonableModPrices)
{
const details = this.ragfairConfig.dynamic.unreasonableModPrices[itemTypeKey];
if (!details?.enabled)
{
continue;
}
for (const itemTpl in fleaPrices)
{
if (!this.itemHelper.isOfBaseclass(itemTpl, itemTypeKey))
{
continue;
}
const itemHandbookPrice = handbookPrices.find((x) => x.Id === itemTpl);
if (!itemHandbookPrice)
{
continue;
}
if (fleaPrices[itemTpl] > (itemHandbookPrice.Price * details.handbookPriceOverMultiplier))
{
if (fleaPrices[itemTpl] <= 1)
{
continue;
}
// Price is over limit, adjust
fleaPrices[itemTpl] = itemHandbookPrice.Price * details.newPriceHandbookMultiplier;
}
}
}
}
/**
* Add tag to profile to indicate when it was made
* @param fullProfile
*/
public addMissingAkiVersionTagToProfile(fullProfile: IAkiProfile): void
{
if (!fullProfile.aki)
{
this.logger.debug("Adding aki object to profile");
fullProfile.aki = { version: this.watermark.getVersionTag(), receivedGifts: [] };
}
}
/**
* 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)
{
for (const counterId in pmcProfile.TaskConditionCounters)
{
const counter = pmcProfile.TaskConditionCounters[counterId];
if (!counter.sourceId)
{
delete pmcProfile.TaskConditionCounters[counterId];
}
}
}
}
public addLighthouseKeeperIfMissing(pmcProfile: IPmcData): void
{
if (!pmcProfile.TradersInfo)
{
return;
}
// only add if other traders exist, means this is pre-patch 13 profile
if (!pmcProfile.TradersInfo[Traders.LIGHTHOUSEKEEPER] && Object.keys(pmcProfile.TradersInfo).length > 0)
{
this.logger.warning("Added missing Lighthouse keeper trader to pmc profile");
pmcProfile.TradersInfo[Traders.LIGHTHOUSEKEEPER] = {
unlocked: false,
disabled: false,
salesSum: 0,
standing: 0.2,
loyaltyLevel: 1,
nextResupply: this.timeUtil.getTimestamp() + 3600, // now + 1 hour
};
}
}
protected addUnlockedInfoObjectIfMissing(pmcProfile: IPmcData): void
{
if (!pmcProfile.UnlockedInfo)
{
this.logger.debug("Adding UnlockedInfo object to profile");
pmcProfile.UnlockedInfo = { unlockedProductionRecipe: [] };
}
}
/**
* 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.databaseServer.getTables().templates.achievements;
// 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 = [];
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;
}
protected fixNullTraderSalesSums(pmcProfile: IPmcData): void
{
for (const traderId in pmcProfile.TradersInfo)
{
const trader = pmcProfile.TradersInfo[traderId];
if (trader && trader.salesSum === null)
{
this.logger.warning(`trader ${traderId} has a null salesSum value, resetting to 0`);
trader.salesSum = 0;
}
}
}
protected addMissingBonusesProperty(pmcProfile: IPmcData): void
{
if (typeof pmcProfile.Bonuses === "undefined")
{
pmcProfile.Bonuses = [];
this.logger.debug("Missing Bonuses property added to profile");
}
}
/**
* Adjust profile quest status and statusTimers object values
* quest.status is numeric e.g. 2
* quest.statusTimers keys are numeric as strings e.g. "2"
* @param profile profile to update
*/
protected updateProfileQuestDataValues(profile: IPmcData): void
{
if (!profile.Quests)
{
return;
}
const fixes = new Map<any, number>();
const questsToDelete: IQuestStatus[] = [];
const fullProfile = this.profileHelper.getFullProfile(profile.sessionId);
const isDevProfile = fullProfile?.info.edition.toLowerCase() === "spt developer";
for (const quest of profile.Quests)
{
// Old profiles had quests with a bad status of 0 (invalid) added to profile, remove them
// E.g. compensation for damage showing before standing check was added to getClientQuests()
if (quest.status === 0 && quest.availableAfter === 0 && !isDevProfile)
{
questsToDelete.push(quest);
continue;
}
if (quest.status && !Number(quest.status))
{
if (fixes.has(quest.status))
{
fixes.set(quest.status, fixes.get(quest.status) + 1);
}
else
{
fixes.set(quest.status, 1);
}
const newQuestStatus = QuestStatus[quest.status];
quest.status = <QuestStatus><unknown>newQuestStatus;
for (const statusTimer in quest.statusTimers)
{
if (!Number(statusTimer))
{
const newKey = QuestStatus[statusTimer];
quest.statusTimers[newKey] = quest.statusTimers[statusTimer];
delete quest.statusTimers[statusTimer];
}
}
}
}
for (const questToDelete of questsToDelete)
{
profile.Quests.splice(profile.Quests.indexOf(questToDelete), 1);
}
if (fixes.size > 0)
{
this.logger.debug(
`Updated quests values: ${
Array.from(fixes.entries()).map(([k, v]) => `(${k}: ${v} times)`).join(", ")
}`,
);
}
}
protected addMissingRepeatableQuestsProperty(pmcProfile: IPmcData): void
{
if (pmcProfile.RepeatableQuests)
{
let repeatablesCompatible = true;
for (const currentRepeatable of pmcProfile.RepeatableQuests)
{
if (
!(currentRepeatable.changeRequirement
&& currentRepeatable.activeQuests.every((
x,
) => (typeof x.changeCost !== "undefined" && typeof x.changeStandingCost !== "undefined")))
)
{
repeatablesCompatible = false;
break;
}
}
if (!repeatablesCompatible)
{
pmcProfile.RepeatableQuests = [];
this.logger.debug("Missing RepeatableQuests property added to profile");
}
}
else
{
pmcProfile.RepeatableQuests = [];
}
}
/**
* Some profiles have hideout maxed and therefore no improvements
* @param pmcProfile Profile to add improvement data to
*/
protected addMissingWallImprovements(pmcProfile: IPmcData): void
{
const profileWallArea = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.EMERGENCY_WALL);
const wallDb = this.databaseServer.getTables().hideout.areas.find((x) =>
x.type === HideoutAreas.EMERGENCY_WALL
);
if (profileWallArea.level > 0)
{
for (let i = 0; i < profileWallArea.level; i++)
{
// Get wall stage from db
const wallStageDb = wallDb.stages[i];
if (wallStageDb.improvements.length === 0)
{
// No improvements, skip
continue;
}
for (const improvement of wallStageDb.improvements)
{
// Don't overwrite existing improvement
if (pmcProfile.Hideout.Improvement[improvement.id])
{
continue;
}
pmcProfile.Hideout.Improvement[improvement.id] = {
completed: true,
improveCompleteTimestamp: this.timeUtil.getTimestamp() + i, // add some variability
};
this.logger.debug(`Added wall improvement ${improvement.id} to profile`);
}
}
}
}
/**
* A new property was added to slot items "locationIndex", if this is missing, the hideout slot item must be removed
* @param pmcProfile Profile to find and remove slots from
*/
protected removeResourcesFromSlotsInHideoutWithoutLocationIndexValue(pmcProfile: IPmcData): void
{
for (const area of pmcProfile.Hideout.Areas)
{
// Skip areas with no resource slots
if (area.slots.length === 0)
{
continue;
}
// Only slots with location index
area.slots = area.slots.filter((x) => "locationIndex" in x);
// Only slots that:
// Have an item property and it has at least one item in it
// Or
// Have no item property
area.slots = area.slots.filter((x) => "item" in x && x.item?.length > 0 || !("item" in x));
}
}
/**
* Hideout slots need to be in a specific order, locationIndex in ascending order
* @param pmcProfile profile to edit
*/
protected reorderHideoutAreasWithResouceInputs(pmcProfile: IPmcData): void
{
const areasToCheck = [
HideoutAreas.AIR_FILTERING,
HideoutAreas.GENERATOR,
HideoutAreas.BITCOIN_FARM,
HideoutAreas.WATER_COLLECTOR,
];
for (const areaId of areasToCheck)
{
const area = pmcProfile.Hideout.Areas.find((x) => x.type === areaId);
if (!area)
{
this.logger.debug(`unable to sort ${areaId} slots, no area found`);
continue;
}
if (!area.slots || area.slots.length === 0)
{
this.logger.debug(`unable to sort ${areaId} slots, no slots found`);
continue;
}
area.slots = area.slots.sort((a, b) =>
{
return a.locationIndex > b.locationIndex ? 1 : -1;
});
}
}
/**
* 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.find((x) => x.locationIndex === i))
{
slots.push({ locationIndex: i });
}
}
return slots;
}
/**
* 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.databaseServer.getTables().hideout.areas;
for (const area of profileHideoutAreas)
{
const areaType = area.type;
const level = area.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[area.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
{
// match by id first, used by "TextBonus" bonuses
if (bonus.id)
{
return profileBonuses.find((x) => x.id === bonus.id);
}
if (bonus.type.toLowerCase() === "stashsize")
{
return profileBonuses.find((x) => x.type === bonus.type && x.templateId === bonus.templateId);
}
if (bonus.type.toLowerCase() === "additionalslots")
{
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);
}
/**
* 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: IAkiProfile): void
{
const itemsDb = this.databaseServer.getTables().templates.items;
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((x) => ["hideout", "main"].includes(x.slotId));
if (!inventoryItemsToCheck)
{
return;
}
// 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);
}
}
}
// Iterate over player-made weapon builds, look for missing items and remove weapon preset if found
for (const buildId in fullProfile.userbuilds?.weaponBuilds)
{
for (const item of fullProfile.userbuilds.weaponBuilds[buildId].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)
{
delete fullProfile.userbuilds.weaponBuilds[buildId];
this.logger.warning(
`Item: ${item._tpl} has resulted in the deletion of weapon build: ${buildId}`,
);
}
break;
}
}
}
// 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 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.databaseServer.getTables().templates.customization;
for (const suitId of fullProfile.suits)
{
if (!clothing[suitId])
{
this.logger.error(this.localisationService.getText("fixer-mod_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 repeatable.activeQuests ?? [])
{
if (!this.traderHelper.traderEnumHasValue(activeQuest.traderId))
{
this.logger.error(this.localisationService.getText("fixer-mod_item_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-mod_item_found", traderId));
if (this.coreConfig.fixes.removeModItemsFromProfile)
{
this.logger.warning(`Non-default trader: ${traderId} removed from traderPurchases list in profile`);
delete fullProfile.traderPurchases[traderId];
}
}
}
}
/**
* 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 itemToAdjustId = pmcProfile.Inventory.items.find(x => x._id === key);
itemToAdjustId._id = this.hashUtil.generate();
this.logger.warning(`Replace duplicate item Id: ${key} with ${itemToAdjustId._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 (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 (null)
if (item.upd.StackObjectsCount === null)
{
this.logger.warning(`Fixed item: ${item._id}s null StackObjectsCount value, now set to 1`);
item.upd.StackObjectsCount = 1;
}
}
// Iterate over clothing
const customizationDb = this.databaseServer.getTables().templates.customization;
const customizationDbArray = Object.values(this.databaseServer.getTables().templates.customization);
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;
}
}
/**
* Add `Improvements` object to hideout if missing - added in eft 13.0.21469
* @param pmcProfile profile to update
*/
public addMissingUpgradesPropertyToHideout(pmcProfile: IPmcData): void
{
if (!pmcProfile.Hideout.Improvement)
{
pmcProfile.Hideout.Improvement = {};
}
}
/**
* Iterate over associated profile template and check all hideout areas exist, add if not
* @param fullProfile Profile to update
*/
public addMissingHideoutAreasToProfile(fullProfile: IAkiProfile): void
{
const pmcProfile = fullProfile.characters.pmc;
// No profile, probably new account being created
if (!pmcProfile?.Hideout)
{
return;
}
const profileTemplates = this.databaseServer.getTables().templates.profiles[fullProfile.info.edition];
if (!profileTemplates)
{
return;
}
const profileTemplate = profileTemplates[pmcProfile.Info.Side.toLowerCase()];
if (!profileTemplate)
{
return;
}
// Get all areas from templates/profiles.json
for (const area of profileTemplate.character.Hideout.Areas)
{
if (!pmcProfile.Hideout.Areas.find((x) => x.type === area.type))
{
pmcProfile.Hideout.Areas.push(area);
this.logger.debug(`Added missing hideout area ${area.type} to profile`);
}
}
}
/**
* These used to be used for storing scav case rewards, rewards are now generated on pickup
* @param pmcProfile Profile to update
*/
public removeLegacyScavCaseProductionCrafts(pmcProfile: IPmcData): void
{
for (const prodKey in pmcProfile.Hideout?.Production)
{
if (prodKey.startsWith("ScavCase"))
{
delete pmcProfile.Hideout.Production[prodKey];
}
}
}
/**
* 3.7.0 moved AIDs to be numeric, old profiles need to be migrated
* We store the old AID value in new field `sessionId`
* @param fullProfile Profile to update
*/
public fixIncorrectAidValue(fullProfile: IAkiProfile): void
{
// Not a number, regenerate
// biome-ignore lint/suspicious/noGlobalIsNan: <value can be a valid string, Number.IsNaN() would ignore it>
if (isNaN(fullProfile.characters.pmc.aid) || !fullProfile.info.aid)
{
fullProfile.characters.pmc.sessionId = <string><unknown>fullProfile.characters.pmc.aid;
fullProfile.characters.pmc.aid = this.hashUtil.generateAccountId();
fullProfile.characters.scav.sessionId = <string><unknown>fullProfile.characters.pmc.sessionId;
fullProfile.characters.scav.aid = fullProfile.characters.pmc.aid;
fullProfile.info.aid = fullProfile.characters.pmc.aid;
this.logger.info(
`Migrated AccountId from: ${fullProfile.characters.pmc.sessionId} to: ${fullProfile.characters.pmc.aid}`,
);
}
}
/**
* Bsg nested `stats` into a sub object called 'eft'
* @param fullProfile Profile to check for and migrate stats data
*/
public migrateStatsToNewStructure(fullProfile: IAkiProfile): void
{
// Data is in old structure, migrate
if ("OverallCounters" in fullProfile.characters.pmc.Stats)
{
this.logger.debug("Migrating stats object into new structure");
const statsCopy = this.jsonUtil.clone(fullProfile.characters.pmc.Stats);
// Clear stats object
fullProfile.characters.pmc.Stats = { Eft: null };
fullProfile.characters.pmc.Stats.Eft = <any><unknown>statsCopy;
}
}
/**
* 26126 (7th August) requires bonuses to have an ID, these were not included in the default profile presets
* @param pmcProfile Profile to add missing IDs to
*/
public addMissingIdsToBonuses(pmcProfile: IPmcData): void
{
let foundBonus = false;
for (const bonus of pmcProfile.Bonuses)
{
if (bonus.id)
{
// Exists already, skip
continue;
}
// Bonus lacks id, find matching hideout area / stage / bonus
for (const area of this.databaseServer.getTables().hideout.areas)
{
// TODO: skip if no stages
for (const stageIndex in area.stages)
{
const stageInfo = area.stages[stageIndex];
const matchingBonus = stageInfo.bonuses.find((x) =>
x.templateId === bonus.templateId && x.type === bonus.type
);
if (matchingBonus)
{
// Add id to bonus, flag bonus as found and exit stage loop
bonus.id = matchingBonus.id;
this.logger.debug(`Added missing Id: ${bonus.id} to bonus: ${bonus.type}`);
foundBonus = true;
break;
}
}
// We've found the bonus we're after, break out of area loop
if (foundBonus)
{
foundBonus = false;
break;
}
}
}
}
/**
* At some point the property name was changed,migrate data across to new name
* @param pmcProfile Profile to migrate improvements in
*/
protected migrateImprovements(pmcProfile: IPmcData): void
{
if ("Improvements" in pmcProfile.Hideout)
{
const improvements = pmcProfile.Hideout.Improvements as Record<string, IHideoutImprovement>;
pmcProfile.Hideout.Improvement = this.jsonUtil.clone(improvements);
delete pmcProfile.Hideout.Improvements;
this.logger.success("Successfully migrated hideout Improvements data to new location, deleted old data");
}
}
/**
* 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.databaseServer.getTables().templates.quests;
const profileQuests = pmcProfile.Quests;
let repeatableQuests: IRepeatableQuest[] = [];
for (const repeatableQuestType of pmcProfile.RepeatableQuests)
{
repeatableQuests.push(...repeatableQuestType.activeQuests);
}
for (let i = 0; i < profileQuests.length; i++)
{
if (!quests[profileQuests[i].qid] && !repeatableQuests.find(x => x._id == profileQuests[i].qid))
{
profileQuests.splice(i, 1);
this.logger.success("Successfully removed orphaned quest that doesnt exist in our quest data");
}
}
}
}