import { inject, injectable } from "tsyringe"; import { HandbookHelper } from "@spt-aki/helpers/HandbookHelper"; import { ItemHelper } from "@spt-aki/helpers/ItemHelper"; import { PresetHelper } from "@spt-aki/helpers/PresetHelper"; import { MinMax } from "@spt-aki/models/common/MinMax"; import { IFenceLevel } from "@spt-aki/models/eft/common/IGlobals"; import { IPmcData } from "@spt-aki/models/eft/common/IPmcData"; import { Item, Repairable, Upd } from "@spt-aki/models/eft/common/tables/IItem"; import { ITemplateItem } from "@spt-aki/models/eft/common/tables/ITemplateItem"; import { IBarterScheme, ITraderAssort } from "@spt-aki/models/eft/common/tables/ITrader"; import { BaseClasses } from "@spt-aki/models/enums/BaseClasses"; import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes"; import { Traders } from "@spt-aki/models/enums/Traders"; import { IItemDurabilityCurrentMax, ITraderConfig } from "@spt-aki/models/spt/config/ITraderConfig"; import { IFenceAssortGenerationValues, IGenerationAssortValues, } from "@spt-aki/models/spt/fence/IFenceAssortGenerationValues"; 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 { JsonUtil } from "@spt-aki/utils/JsonUtil"; import { RandomUtil } from "@spt-aki/utils/RandomUtil"; import { TimeUtil } from "@spt-aki/utils/TimeUtil"; /** * Handle actions surrounding Fence * e.g. generating or refreshing assorts / get next refresh time */ @injectable() export class FenceService { protected traderConfig: ITraderConfig; /** Time when some items in assort will be replaced */ protected nextPartialRefreshTimestamp: number; /** Main assorts you see at all rep levels */ protected fenceAssort: ITraderAssort = undefined; /** Assorts shown on a separate tab when you max out fence rep */ protected fenceDiscountAssort: ITraderAssort = undefined; /** Hydrated on initial assort generation as part of generateFenceAssorts() */ protected desiredAssortCounts: IFenceAssortGenerationValues; constructor( @inject("WinstonLogger") protected logger: ILogger, @inject("JsonUtil") protected jsonUtil: JsonUtil, @inject("TimeUtil") protected timeUtil: TimeUtil, @inject("RandomUtil") protected randomUtil: RandomUtil, @inject("DatabaseServer") protected databaseServer: DatabaseServer, @inject("HandbookHelper") protected handbookHelper: HandbookHelper, @inject("ItemHelper") protected itemHelper: ItemHelper, @inject("PresetHelper") protected presetHelper: PresetHelper, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("ConfigServer") protected configServer: ConfigServer, ) { this.traderConfig = this.configServer.getConfig(ConfigTypes.TRADER); } /** * Replace main fence assort with new assort * @param assort New assorts to replace old with */ public setFenceAssort(assort: ITraderAssort): void { this.fenceAssort = assort; } /** * Replace high rep level fence assort with new assort * @param discountAssort New assorts to replace old with */ public setFenceDiscountAssort(discountAssort: ITraderAssort): void { this.fenceDiscountAssort = discountAssort; } /** * Get assorts player can purchase * Adjust prices based on fence level of player * @param pmcProfile Player profile * @returns ITraderAssort */ public getFenceAssorts(pmcProfile: IPmcData): ITraderAssort { if (this.traderConfig.fence.regenerateAssortsOnRefresh) { // Using base assorts made earlier, do some alterations and store in this.fenceAssort this.generateFenceAssorts(); } // Clone assorts so we can adjust prices before sending to client const assort = this.jsonUtil.clone(this.fenceAssort); this.adjustAssortItemPricesByConfigMultiplier(assort, 1, this.traderConfig.fence.presetPriceMult); // merge normal fence assorts + discount assorts if player standing is large enough if (pmcProfile.TradersInfo[Traders.FENCE].standing >= 6) { const discountAssort = this.jsonUtil.clone(this.fenceDiscountAssort); this.adjustAssortItemPricesByConfigMultiplier( discountAssort, this.traderConfig.fence.discountOptions.itemPriceMult, this.traderConfig.fence.discountOptions.presetPriceMult, ); const mergedAssorts = this.mergeAssorts(assort, discountAssort); return mergedAssorts; } return assort; } /** * Adjust all items contained inside an assort by a multiplier * @param assort (clone)Assort that contains items with prices to adjust * @param itemMultipler multipler to use on items * @param presetMultiplier preset multipler to use on presets */ protected adjustAssortItemPricesByConfigMultiplier( assort: ITraderAssort, itemMultipler: number, presetMultiplier: number, ): void { for (const item of assort.items) { // Skip sub-items when adjusting prices if (item.slotId !== "hideout") { continue; } this.adjustItemPriceByModifier(item, assort, itemMultipler, presetMultiplier); } } /** * Merge two trader assort files together * @param firstAssort assort 1# * @param secondAssort assort #2 * @returns merged assort */ protected mergeAssorts(firstAssort: ITraderAssort, secondAssort: ITraderAssort): ITraderAssort { for (const itemId in secondAssort.barter_scheme) { firstAssort.barter_scheme[itemId] = secondAssort.barter_scheme[itemId]; } for (const item of secondAssort.items) { firstAssort.items.push(item); } for (const itemId in secondAssort.loyal_level_items) { firstAssort.loyal_level_items[itemId] = secondAssort.loyal_level_items[itemId]; } return firstAssort; } /** * Adjust assorts price by a modifier * @param item assort item details * @param assort assort to be modified * @param modifier value to multiply item price by * @param presetModifier value to multiply preset price by */ protected adjustItemPriceByModifier( item: Item, assort: ITraderAssort, modifier: number, presetModifier: number, ): void { // Is preset if (item.upd.sptPresetId) { if (assort.barter_scheme[item._id]) { assort.barter_scheme[item._id][0][0].count *= presetModifier; } } else if (assort.barter_scheme[item._id]) { assort.barter_scheme[item._id][0][0].count *= modifier; } else { this.logger.warning(`adjustItemPriceByModifier() - no action taken for item: ${item._tpl}`); return; } } /** * Get fence assorts with no price adjustments based on fence rep * @returns ITraderAssort */ public getRawFenceAssorts(): ITraderAssort { return this.mergeAssorts(this.jsonUtil.clone(this.fenceAssort), this.jsonUtil.clone(this.fenceDiscountAssort)); } /** * Does fence need to perform a partial refresh because its passed the refresh timer defined in trader.json * @returns true if it needs a partial refresh */ public needsPartialRefresh(): boolean { return this.timeUtil.getTimestamp() > this.nextPartialRefreshTimestamp; } /** * Replace a percentage of fence assorts with freshly generated items */ public performPartialRefresh(): void { const itemCountToReplace = this.getCountOfItemsToReplace(this.traderConfig.fence.assortSize); const discountItemCountToReplace = this.getCountOfItemsToReplace( this.traderConfig.fence.discountOptions.assortSize, ); // Simulate players buying items this.deleteRandomAssorts(itemCountToReplace, this.fenceAssort); this.deleteRandomAssorts(discountItemCountToReplace, this.fenceDiscountAssort); // Get count of what item pools need new items (item/weapon/equipment) const itemCountsToReplace = this.getCountOfItemsToGenerate(); const newItems = this.createFenceAssortSkeleton(); this.createAssorts(itemCountsToReplace.normal, newItems, 2); this.fenceAssort.items.push(...newItems.items); const newDiscountItems = this.createFenceAssortSkeleton(); this.createAssorts(itemCountsToReplace.discount, newDiscountItems, 2); this.fenceDiscountAssort.items.push(...newDiscountItems.items); // Add new barter items to fence barter scheme for (const barterItemKey in newItems.barter_scheme) { this.fenceAssort.barter_scheme[barterItemKey] = newItems.barter_scheme[barterItemKey]; } // Add loyalty items to fence assorts loyalty object for (const loyaltyItemKey in newItems.loyal_level_items) { this.fenceAssort.loyal_level_items[loyaltyItemKey] = newItems.loyal_level_items[loyaltyItemKey]; } // Add new barter items to fence assorts discounted barter scheme for (const barterItemKey in newDiscountItems.barter_scheme) { this.fenceDiscountAssort.barter_scheme[barterItemKey] = newDiscountItems.barter_scheme[barterItemKey]; } // Add loyalty items to fence discount assorts loyalty object for (const loyaltyItemKey in newDiscountItems.loyal_level_items) { this.fenceDiscountAssort.loyal_level_items[loyaltyItemKey] = newDiscountItems.loyal_level_items[loyaltyItemKey]; } // Reset the clock this.incrementPartialRefreshTime(); } /** * Increment fence next refresh timestamp by current timestamp + partialRefreshTimeSeconds from config */ protected incrementPartialRefreshTime(): void { this.nextPartialRefreshTimestamp = this.timeUtil.getTimestamp() + this.traderConfig.fence.partialRefreshTimeSeconds; } /** * Compare the current fence offer count to what the config wants it to be, * If value is lower add extra count to value to generate more items to fill gap * @param existingItemCountToReplace count of items to generate * @returns number of items to generate */ protected getCountOfItemsToGenerate(): IFenceAssortGenerationValues { const currentItemAssortCount = Object.keys(this.fenceAssort.loyal_level_items).length; const rootPresetItems = this.fenceAssort.items.filter((item) => item.slotId === "hideout" && item.upd.sptPresetId ); // Get count of weapons const currentWeaponPresetCount = rootPresetItems.reduce((count, item) => { return this.itemHelper.isOfBaseclass(item._tpl, BaseClasses.WEAPON) ? count + 1 : count; }, 0); // Get count of equipment const currentEquipmentPresetCount = rootPresetItems.reduce((count, item) => { return this.itemHelper.armorItemCanHoldMods(item._tpl) ? count + 1 : count; }, 0); const itemCountToGenerate = Math.max(this.desiredAssortCounts.normal.item - currentItemAssortCount, 0); const weaponCountToGenerate = Math.max( this.desiredAssortCounts.normal.weaponPreset - currentWeaponPresetCount, 0, ); const equipmentCountToGenerate = Math.max( this.desiredAssortCounts.normal.equipmentPreset - currentEquipmentPresetCount, 0, ); const normalValues: IGenerationAssortValues = { item: itemCountToGenerate, weaponPreset: weaponCountToGenerate, equipmentPreset: equipmentCountToGenerate, }; // Discount tab handling const currentDiscountItemAssortCount = Object.keys(this.fenceDiscountAssort.loyal_level_items).length; const rootDiscountPresetItems = this.fenceDiscountAssort.items.filter((item) => item.slotId === "hideout" && item.upd.sptPresetId ); // Get count of weapons const currentDiscountWeaponPresetCount = rootDiscountPresetItems.reduce((count, item) => { return this.itemHelper.isOfBaseclass(item._tpl, BaseClasses.WEAPON) ? count + 1 : count; }, 0); // Get count of equipment const currentDiscountEquipmentPresetCount = rootDiscountPresetItems.reduce((count, item) => { return this.itemHelper.armorItemCanHoldMods(item._tpl) ? count + 1 : count; }, 0); const itemDiscountCountToGenerate = Math.max( this.desiredAssortCounts.discount.item - currentDiscountItemAssortCount, 0, ); const weaponDiscountCountToGenerate = Math.max( this.desiredAssortCounts.discount.weaponPreset - currentDiscountWeaponPresetCount, 0, ); const equipmentDiscountCountToGenerate = Math.max( this.desiredAssortCounts.discount.equipmentPreset - currentDiscountEquipmentPresetCount, 0, ); const discountValues: IGenerationAssortValues = { item: itemDiscountCountToGenerate, weaponPreset: weaponDiscountCountToGenerate, equipmentPreset: equipmentDiscountCountToGenerate, }; return { normal: normalValues, discount: discountValues }; } /** * Delete desired number of items from assort (including children) * @param itemCountToReplace * @param discountItemCountToReplace */ protected deleteRandomAssorts(itemCountToReplace: number, assort: ITraderAssort): void { if (assort?.items?.length > 0) { const rootItems = assort.items.filter((item) => item.slotId === "hideout"); for (let index = 0; index < itemCountToReplace; index++) { this.removeRandomItemFromAssorts(assort, rootItems); } } } /** * Choose an item at random and remove it + mods from assorts * @param assort Items to remove from * @param rootItems Assort root items to pick from to remove */ protected removeRandomItemFromAssorts(assort: ITraderAssort, rootItems: Item[]): void { const rootItemToRemove = this.randomUtil.getArrayValue(rootItems); // Clean up any mods if item had them const itemWithChildren = this.itemHelper.findAndReturnChildrenAsItems(assort.items, rootItemToRemove._id); for (const itemToDelete of itemWithChildren) { // Delete item from assort items array assort.items.splice(assort.items.indexOf(itemToDelete), 1); } delete assort.barter_scheme[rootItemToRemove._id]; delete assort.loyal_level_items[rootItemToRemove._id]; } /** * Get an integer rounded count of items to replace based on percentrage from traderConfig value * @param totalItemCount total item count * @returns rounded int of items to replace */ protected getCountOfItemsToReplace(totalItemCount: number): number { return Math.round(totalItemCount * (this.traderConfig.fence.partialRefreshChangePercent / 100)); } /** * Get the count of items fence offers * @returns number */ public getOfferCount(): number { if (!this.fenceAssort?.items?.length) { return 0; } return this.fenceAssort.items.length; } /** * Create trader assorts for fence and store in fenceService cache * Uses fence base cache generatedon server start as a base */ public generateFenceAssorts(): void { // Reset refresh time now assorts are being generated this.incrementPartialRefreshTime(); // Choose assort counts using config this.createInitialFenceAssortGenerationValues(); // Create basic fence assort const assorts = this.createFenceAssortSkeleton(); this.createAssorts(this.desiredAssortCounts.normal, assorts, 1); // Store in this.fenceAssort this.setFenceAssort(assorts); // Create level 2 assorts accessible at rep level 6 const discountAssorts = this.createFenceAssortSkeleton(); this.createAssorts(this.desiredAssortCounts.discount, discountAssorts, 2); // Store in this.fenceDiscountAssort this.setFenceDiscountAssort(discountAssorts); } /** * Create object that contains calculated fence assort item values to make based on config * Stored in this.desiredAssortCounts */ protected createInitialFenceAssortGenerationValues(): void { const result: IFenceAssortGenerationValues = { normal: { item: 0, weaponPreset: 0, equipmentPreset: 0 }, discount: { item: 0, weaponPreset: 0, equipmentPreset: 0 }, }; result.normal.item = this.traderConfig.fence.assortSize; result.normal.weaponPreset = this.randomUtil.getInt( this.traderConfig.fence.weaponPresetMinMax.min, this.traderConfig.fence.weaponPresetMinMax.max, ); result.normal.equipmentPreset = this.randomUtil.getInt( this.traderConfig.fence.equipmentPresetMinMax.min, this.traderConfig.fence.equipmentPresetMinMax.max, ); result.discount.item = this.traderConfig.fence.discountOptions.assortSize; result.discount.weaponPreset = this.randomUtil.getInt( this.traderConfig.fence.discountOptions.weaponPresetMinMax.min, this.traderConfig.fence.discountOptions.weaponPresetMinMax.max, ); result.discount.equipmentPreset = this.randomUtil.getInt( this.traderConfig.fence.discountOptions.equipmentPresetMinMax.min, this.traderConfig.fence.discountOptions.equipmentPresetMinMax.max, ); this.desiredAssortCounts = result; } /** * Create skeleton to hold assort items * @returns ITraderAssort object */ protected createFenceAssortSkeleton(): ITraderAssort { return { items: [], barter_scheme: {}, loyal_level_items: {}, nextResupply: this.getNextFenceUpdateTimestamp(), }; } /** * Hydrate assorts parameter object with generated assorts * @param assortCount Number of assorts to generate * @param assorts object to add created assorts to */ protected createAssorts(itemCounts: IGenerationAssortValues, assorts: ITraderAssort, loyaltyLevel: number): void { const baseFenceAssortClone = this.jsonUtil.clone(this.databaseServer.getTables().traders[Traders.FENCE].assort); const itemTypeLimitCounts = this.initItemLimitCounter(this.traderConfig.fence.itemTypeLimits); if (itemCounts.item > 0) { this.addItemAssorts(itemCounts.item, assorts, baseFenceAssortClone, itemTypeLimitCounts, loyaltyLevel); } if (itemCounts.weaponPreset > 0 || itemCounts.equipmentPreset > 0) { // Add presets this.addPresetsToAssort( itemCounts.weaponPreset, itemCounts.equipmentPreset, assorts, baseFenceAssortClone, loyaltyLevel, ); } } /** * Add item assorts to existing assort data * @param assortCount Number to add * @param assorts Assorts data to add to * @param baseFenceAssortClone Base data to draw from * @param itemTypeLimits * @param loyaltyLevel Loyalty level to set new item to */ protected addItemAssorts( assortCount: number, assorts: ITraderAssort, baseFenceAssortClone: ITraderAssort, itemTypeLimits: Record, loyaltyLevel: number, ): void { const priceLimits = this.traderConfig.fence.itemCategoryRoublePriceLimit; const assortRootItems = baseFenceAssortClone.items.filter((x) => x.parentId === "hideout" && !x.upd?.sptPresetId ); for (let i = 0; i < assortCount; i++) { const chosenBaseAssortRoot = this.randomUtil.getArrayValue(assortRootItems); if (!chosenBaseAssortRoot) { this.logger.error( this.localisationService.getText("fence-unable_to_find_assort_by_id", chosenBaseAssortRoot._id), ); continue; } let desiredAssortItemAndChildrenClone = this.jsonUtil.clone( this.itemHelper.findAndReturnChildrenAsItems(baseFenceAssortClone.items, chosenBaseAssortRoot._id), ); const itemDbDetails = this.itemHelper.getItem(chosenBaseAssortRoot._tpl)[1]; const itemLimitCount = this.getMatchingItemLimit(itemTypeLimits, itemDbDetails._id); if (itemLimitCount?.current >= itemLimitCount?.max) { // Skip adding item as assort as limit reached, decrement i counter so we still get another item i--; continue; } const itemIsPreset = this.presetHelper.isPreset(chosenBaseAssortRoot._id); const price = baseFenceAssortClone.barter_scheme[chosenBaseAssortRoot._id][0][0].count; if (price === 0 || (price === 1 && !itemIsPreset) || price === 100) { // Don't allow "special" items / presets i--; continue; } if (price > priceLimits[itemDbDetails._parent]) { // Too expensive for fence, try another item i--; continue; } // Increment count as item is being added if (itemLimitCount) { itemLimitCount.current++; } // MUST randomise Ids as its possible to add the same base fence assort twice = duplicate IDs = dead client desiredAssortItemAndChildrenClone = this.itemHelper.replaceIDs(desiredAssortItemAndChildrenClone); this.itemHelper.remapRootItemId(desiredAssortItemAndChildrenClone); const rootItemBeingAdded = desiredAssortItemAndChildrenClone[0]; // Set stack size based on possible overrides, e.g. ammos, otherwise set to 1 rootItemBeingAdded.upd.StackObjectsCount = this.getSingleItemStackCount(itemDbDetails); // Only randomise upd values for single const isSingleStack = rootItemBeingAdded.upd.StackObjectsCount === 1; if (isSingleStack) { this.randomiseItemUpdProperties(itemDbDetails, rootItemBeingAdded); } // Skip items already in the assort if it exists in the prevent duplicate list const existingItemThatMatches = this.getMatchingItem(rootItemBeingAdded, itemDbDetails, assorts.items); const shouldBeStacked = this.itemShouldBeForceStacked(existingItemThatMatches, itemDbDetails); if (shouldBeStacked && existingItemThatMatches) { // Decrement loop counter so another items gets added i--; existingItemThatMatches.upd.StackObjectsCount++; continue; } // Add mods to armors so they dont show as red in the trade screen if (this.itemHelper.itemRequiresSoftInserts(rootItemBeingAdded._tpl)) { this.randomiseArmorModDurability(desiredAssortItemAndChildrenClone, itemDbDetails); } assorts.items.push(...desiredAssortItemAndChildrenClone); assorts.barter_scheme[rootItemBeingAdded._id] = this.jsonUtil.clone( baseFenceAssortClone.barter_scheme[chosenBaseAssortRoot._id], ); // Only adjust item price by quality for solo items, never multi-stack if (isSingleStack) { this.adjustItemPriceByQuality(assorts.barter_scheme, rootItemBeingAdded, itemDbDetails); } assorts.loyal_level_items[rootItemBeingAdded._id] = loyaltyLevel; } } /** * Find an assort item that matches the first parameter, also matches based on upd properties * e.g. salewa hp resource units left * @param rootItemBeingAdded item to look for a match against * @param itemDbDetails Db details of matching item * @param fenceItemAssorts Items to search through * @returns Matching assort item */ protected getMatchingItem(rootItemBeingAdded: Item, itemDbDetails: ITemplateItem, fenceItemAssorts: Item[]): Item { const matchingItems = fenceItemAssorts.filter((item) => item._tpl === rootItemBeingAdded._tpl); if (matchingItems.length === 0) { // Nothing matches by tpl, exit early return null; } const isMedical = this.itemHelper.isOfBaseclasses(rootItemBeingAdded._tpl, [ BaseClasses.MEDICAL, BaseClasses.MEDKIT, ]); const isGearAndHasSlots = this.itemHelper.isOfBaseclasses(rootItemBeingAdded._tpl, [ BaseClasses.ARMORED_EQUIPMENT, BaseClasses.SEARCHABLE_ITEM, ]) && itemDbDetails._props.Slots.length > 0; // Only one match and its not medical or armored gear if (matchingItems.length === 1 && (!(isMedical || isGearAndHasSlots))) { return matchingItems[0]; } // Items have sub properties that need to be checked against for (const item of matchingItems) { if (isMedical && rootItemBeingAdded.upd.MedKit?.HpResource === item.upd.MedKit?.HpResource) { // e.g. bandages with multiple use // Both null === both max resoruce left return item; } // Armors/helmets etc if ( isGearAndHasSlots && rootItemBeingAdded.upd.Repairable?.Durability === item.upd.Repairable?.Durability && rootItemBeingAdded.upd.Repairable?.MaxDurability === item.upd.Repairable?.MaxDurability ) { return item; } } return null; } /** * Should this item be forced into only 1 stack on fence * @param existingItem Existing item from fence assort * @param itemDbDetails Item we want to add db details * @returns True item should be force stacked */ protected itemShouldBeForceStacked(existingItem: Item, itemDbDetails: ITemplateItem): boolean { // No existing item in assort if (!existingItem) { return false; } // Item type in config list return this.itemHelper.isOfBaseclasses( itemDbDetails._id, this.traderConfig.fence.preventDuplicateOffersOfCategory, ); } /** * Adjust price of item based on what is left to buy (resource/uses left) * @param barterSchemes All barter scheme for item having price adjusted * @param itemRoot Root item having price adjusted * @param itemTemplate Db template of item */ protected adjustItemPriceByQuality( barterSchemes: Record, itemRoot: Item, itemTemplate: ITemplateItem, ): void { // Healing items if (itemRoot.upd?.MedKit) { const itemTotalMax = itemTemplate._props.MaxHpResource; const current = itemRoot.upd.MedKit.HpResource; // Current and max match, no adjustment necessary if (itemTotalMax === current) { return; } const multipler = current / itemTotalMax; // Multiply item cost by desired multiplier const basePrice = barterSchemes[itemRoot._id][0][0].count; barterSchemes[itemRoot._id][0][0].count = Math.round(basePrice * multipler); return; } // Adjust price based on durability if (itemRoot.upd?.Repairable || this.itemHelper.isOfBaseclass(itemRoot._tpl, BaseClasses.KEY_MECHANICAL)) { const itemQualityModifier = this.itemHelper.getItemQualityModifier(itemRoot); const basePrice = barterSchemes[itemRoot._id][0][0].count; barterSchemes[itemRoot._id][0][0].count = Math.round(basePrice * itemQualityModifier); } } protected getMatchingItemLimit( itemTypeLimits: Record, itemTpl: string, ): { current: number; max: number; } { for (const baseTypeKey in itemTypeLimits) { if (this.itemHelper.isOfBaseclass(itemTpl, baseTypeKey)) { return itemTypeLimits[baseTypeKey]; } } } /** * Find presets in base fence assort and add desired number to 'assorts' parameter * @param desiredWeaponPresetsCount * @param assorts Assorts to add preset to * @param baseFenceAssort Base data to draw from * @param loyaltyLevel Which loyalty level is required to see/buy item */ protected addPresetsToAssort( desiredWeaponPresetsCount: number, desiredEquipmentPresetsCount: number, assorts: ITraderAssort, baseFenceAssort: ITraderAssort, loyaltyLevel: number, ): void { let weaponPresetsAddedCount = 0; if (desiredWeaponPresetsCount > 0) { const weaponPresetRootItems = baseFenceAssort.items.filter((item) => item.upd?.sptPresetId && this.itemHelper.isOfBaseclass(item._tpl, BaseClasses.WEAPON) ); while (weaponPresetsAddedCount < desiredWeaponPresetsCount) { const randomPresetRoot = this.randomUtil.getArrayValue(weaponPresetRootItems); if (this.traderConfig.fence.blacklist.includes(randomPresetRoot._tpl)) { continue; } const rootItemDb = this.itemHelper.getItem(randomPresetRoot._tpl)[1]; const presetWithChildrenClone = this.jsonUtil.clone( this.itemHelper.findAndReturnChildrenAsItems(baseFenceAssort.items, randomPresetRoot._id), ); this.randomiseItemUpdProperties(rootItemDb, presetWithChildrenClone[0]); this.removeRandomModsOfItem(presetWithChildrenClone); // Check chosen item is below price cap const priceLimitRouble = this.traderConfig.fence.itemCategoryRoublePriceLimit[rootItemDb._parent]; const itemPrice = this.handbookHelper.getTemplatePriceForItems(presetWithChildrenClone) * this.itemHelper.getItemQualityModifierForOfferItems(presetWithChildrenClone); if (priceLimitRouble) { if (itemPrice > priceLimitRouble) { // Too expensive, try again continue; } } // MUST randomise Ids as its possible to add the same base fence assort twice = duplicate IDs = dead client this.itemHelper.reparentItemAndChildren(presetWithChildrenClone[0], presetWithChildrenClone); this.itemHelper.remapRootItemId(presetWithChildrenClone); // Remapping IDs causes parentid to be altered presetWithChildrenClone[0].parentId = "hideout"; assorts.items.push(...presetWithChildrenClone); // Set assort price // Must be careful to use correct id as the item has had its IDs regenerated assorts.barter_scheme[presetWithChildrenClone[0]._id] = [[{ _tpl: "5449016a4bdc2d6f028b456f", count: Math.round(itemPrice), }]]; assorts.loyal_level_items[presetWithChildrenClone[0]._id] = loyaltyLevel; weaponPresetsAddedCount++; } } let equipmentPresetsAddedCount = 0; if (desiredEquipmentPresetsCount <= 0) { return; } const equipmentPresetRootItems = baseFenceAssort.items.filter((item) => item.upd?.sptPresetId && this.itemHelper.armorItemCanHoldMods(item._tpl) ); while (equipmentPresetsAddedCount < desiredEquipmentPresetsCount) { const randomPresetRoot = this.randomUtil.getArrayValue(equipmentPresetRootItems); const rootItemDb = this.itemHelper.getItem(randomPresetRoot._tpl)[1]; const presetWithChildrenClone = this.jsonUtil.clone( this.itemHelper.findAndReturnChildrenAsItems(baseFenceAssort.items, randomPresetRoot._id), ); // Need to add mods to armors so they dont show as red in the trade screen if (this.itemHelper.itemRequiresSoftInserts(randomPresetRoot._tpl)) { this.randomiseArmorModDurability(presetWithChildrenClone, rootItemDb); } this.removeRandomModsOfItem(presetWithChildrenClone); // Check chosen item is below price cap const priceLimitRouble = this.traderConfig.fence.itemCategoryRoublePriceLimit[rootItemDb._parent]; const itemPrice = this.handbookHelper.getTemplatePriceForItems(presetWithChildrenClone) * this.itemHelper.getItemQualityModifierForOfferItems(presetWithChildrenClone); if (priceLimitRouble) { if (itemPrice > priceLimitRouble) { // Too expensive, try again continue; } } // MUST randomise Ids as its possible to add the same base fence assort twice = duplicate IDs = dead client this.itemHelper.reparentItemAndChildren(presetWithChildrenClone[0], presetWithChildrenClone); this.itemHelper.remapRootItemId(presetWithChildrenClone); // Remapping IDs causes parentid to be altered presetWithChildrenClone[0].parentId = "hideout"; assorts.items.push(...presetWithChildrenClone); // Set assort price // Must be careful to use correct id as the item has had its IDs regenerated assorts.barter_scheme[presetWithChildrenClone[0]._id] = [[{ _tpl: "5449016a4bdc2d6f028b456f", count: Math.round(itemPrice), }]]; assorts.loyal_level_items[presetWithChildrenClone[0]._id] = loyaltyLevel; equipmentPresetsAddedCount++; } } /** * Adjust plate / soft insert durability values * @param armor Armor item array to add mods into * @param itemDbDetails Armor items db template */ protected randomiseArmorModDurability(armor: Item[], itemDbDetails: ITemplateItem): void { // Armor has no mods, make no changes const hasMods = itemDbDetails._props.Slots.length > 0; if (!hasMods) { return; } // Check for and adjust soft insert durability values const requiredSlots = itemDbDetails._props.Slots.filter((slot) => slot._required); const hasRequiredSlots = requiredSlots.length > 0; if (hasRequiredSlots) { for (const requiredSlot of requiredSlots) { const modItemDbDetails = this.itemHelper.getItem(requiredSlot._props.filters[0].Plate)[1]; const durabilityValues = this.getRandomisedArmorDurabilityValues( modItemDbDetails, this.traderConfig.fence.armorMaxDurabilityPercentMinMax, ); const plateTpl = requiredSlot._props.filters[0].Plate; // `Plate` property appears to be the 'default' item for slot if (plateTpl === "") { // Some bsg plate properties are empty, skip mod continue; } // Find items mod to apply dura changes to const modItemToAdjust = armor.find((mod) => mod.slotId.toLowerCase() === requiredSlot._name.toLowerCase() ); this.itemHelper.addUpdObjectToItem(modItemToAdjust); if (!modItemToAdjust.upd.Repairable) { modItemToAdjust.upd.Repairable = { Durability: modItemDbDetails._props.MaxDurability, MaxDurability: modItemDbDetails._props.MaxDurability, }; } modItemToAdjust.upd.Repairable.Durability = durabilityValues.Durability; modItemToAdjust.upd.Repairable.MaxDurability = durabilityValues.MaxDurability; // 25% chance to add shots to visor when its below max durability if ( this.randomUtil.getChance100(25) && modItemToAdjust.parentId === BaseClasses.ARMORED_EQUIPMENT && modItemToAdjust.slotId === "mod_equipment_000" && modItemToAdjust.upd.Repairable.Durability < modItemDbDetails._props.MaxDurability ) { // Is damaged modItemToAdjust.upd.FaceShield = { Hits: this.randomUtil.getInt(1, 3) }; } } } // Check for and adjust plate durability values const plateSlots = itemDbDetails._props.Slots.filter((slot) => this.itemHelper.isRemovablePlateSlot(slot._name) ); if (plateSlots.length > 0) { for (const plateSlot of plateSlots) { // Chance to not add plate if (!this.randomUtil.getChance100(this.traderConfig.fence.chancePlateExistsInArmorPercent)) { continue; } const plateTpl = plateSlot._props.filters[0].Plate; if (!plateTpl) { // Bsg data lacks a default plate, skip adding mod continue; } const modItemDbDetails = this.itemHelper.getItem(plateTpl)[1]; const durabilityValues = this.getRandomisedArmorDurabilityValues( modItemDbDetails, this.traderConfig.fence.armorMaxDurabilityPercentMinMax, ); // Find items mod to apply dura changes to const modItemToAdjust = armor.find((mod) => mod.slotId.toLowerCase() === plateSlot._name.toLowerCase()); this.itemHelper.addUpdObjectToItem(modItemToAdjust); if (!modItemToAdjust.upd.Repairable) { modItemToAdjust.upd.Repairable = { Durability: modItemDbDetails._props.MaxDurability, MaxDurability: modItemDbDetails._props.MaxDurability, }; } modItemToAdjust.upd.Repairable.Durability = durabilityValues.Durability; modItemToAdjust.upd.Repairable.MaxDurability = durabilityValues.MaxDurability; } } } /** * Get stack size of a singular item (no mods) * @param itemDbDetails item being added to fence * @returns Stack size */ protected getSingleItemStackCount(itemDbDetails: ITemplateItem): number { if (this.itemHelper.isOfBaseclass(itemDbDetails._id, BaseClasses.AMMO)) { const overrideValues = this.traderConfig.fence.itemStackSizeOverrideMinMax[itemDbDetails._parent]; if (overrideValues) { return this.randomUtil.getInt(overrideValues.min, overrideValues.max); } // No override, use stack max size from item db return itemDbDetails._props.StackMaxSize === 1 ? 1 : this.randomUtil.getInt(itemDbDetails._props.StackMinRandom, itemDbDetails._props.StackMaxRandom); } // Check for override in config, use values if exists let overrideValues = this.traderConfig.fence.itemStackSizeOverrideMinMax[itemDbDetails._id]; if (overrideValues) { return this.randomUtil.getInt(overrideValues.min, overrideValues.max); } // Check for parent override overrideValues = this.traderConfig.fence.itemStackSizeOverrideMinMax[itemDbDetails._parent]; if (overrideValues) { return this.randomUtil.getInt(overrideValues.min, overrideValues.max); } return 1; } /** * Remove parts of a weapon prior to being listed on flea * @param itemAndMods Weapon to remove parts from */ protected removeRandomModsOfItem(itemAndMods: Item[]): void { // Items to be removed from inventory const toDelete: string[] = []; // Find mods to remove from item that could've been scavenged by other players in-raid for (const itemMod of itemAndMods) { if (this.presetModItemWillBeRemoved(itemMod, toDelete)) { // Skip if not an item const itemDbDetails = this.itemHelper.getItem(itemMod._tpl); if (!itemDbDetails[0]) { continue; } // Remove item and its sub-items to prevent orphans toDelete.push(...this.itemHelper.findAndReturnChildrenByItems(itemAndMods, itemMod._id)); } } // Reverse loop and remove items for (let index = itemAndMods.length - 1; index >= 0; --index) { if (toDelete.includes(itemAndMods[index]._id)) { itemAndMods.splice(index, 1); } } } /** * Roll % chance check to see if item should be removed * @param weaponMod Weapon mod being checked * @param itemsBeingDeleted Current list of items on weapon being deleted * @returns True if item will be removed */ protected presetModItemWillBeRemoved(weaponMod: Item, itemsBeingDeleted: string[]): boolean { const slotIdsThatCanFail = this.traderConfig.fence.presetSlotsToRemoveChancePercent; const removalChance = slotIdsThatCanFail[weaponMod.slotId]; if (!removalChance) { return false; } // Roll from 0 to 9999, then divide it by 100: 9999 = 99.99% const randomChance = this.randomUtil.getInt(0, 9999) / 100; return removalChance > randomChance && !itemsBeingDeleted.includes(weaponMod._id); } /** * Randomise items' upd properties e.g. med packs/weapons/armor * @param itemDetails Item being randomised * @param itemToAdjust Item being edited */ protected randomiseItemUpdProperties(itemDetails: ITemplateItem, itemToAdjust: Item): void { if (!itemDetails._props) { this.logger.error( `Item ${itemDetails._name} lacks a _props field, unable to randomise item: ${itemToAdjust._id}`, ); return; } // Randomise hp resource of med items if ("MaxHpResource" in itemDetails._props && itemDetails._props.MaxHpResource > 0) { itemToAdjust.upd.MedKit = { HpResource: this.randomUtil.getInt(1, itemDetails._props.MaxHpResource) }; } // Randomise armor durability if ( (itemDetails._parent === BaseClasses.ARMORED_EQUIPMENT || itemDetails._parent === BaseClasses.FACECOVER || itemDetails._parent === BaseClasses.ARMOR_PLATE) && itemDetails._props.MaxDurability > 0 ) { const values = this.getRandomisedArmorDurabilityValues( itemDetails, this.traderConfig.fence.armorMaxDurabilityPercentMinMax, ); itemToAdjust.upd.Repairable = { Durability: values.Durability, MaxDurability: values.MaxDurability }; return; } // Randomise Weapon durability if (this.itemHelper.isOfBaseclass(itemDetails._id, BaseClasses.WEAPON)) { const weaponDurabilityLimits = this.traderConfig.fence.weaponDurabilityPercentMinMax; const maxDuraMin = weaponDurabilityLimits.max.min / 100 * itemDetails._props.MaxDurability; const maxDuraMax = weaponDurabilityLimits.max.max / 100 * itemDetails._props.MaxDurability; const chosenMaxDurability = this.randomUtil.getInt(maxDuraMin, maxDuraMax); const currentDuraMin = weaponDurabilityLimits.current.min / 100 * itemDetails._props.MaxDurability; const currentDuraMax = weaponDurabilityLimits.current.max / 100 * itemDetails._props.MaxDurability; const currentDurability = Math.min( this.randomUtil.getInt(currentDuraMin, currentDuraMax), chosenMaxDurability, ); itemToAdjust.upd.Repairable = { Durability: currentDurability, MaxDurability: chosenMaxDurability }; return; } if (this.itemHelper.isOfBaseclass(itemDetails._id, BaseClasses.REPAIR_KITS)) { itemToAdjust.upd.RepairKit = { Resource: this.randomUtil.getInt(1, itemDetails._props.MaxRepairResource) }; return; } // Mechanical key + has limited uses if ( this.itemHelper.isOfBaseclass(itemDetails._id, BaseClasses.KEY_MECHANICAL) && itemDetails._props.MaximumNumberOfUsage > 1 ) { itemToAdjust.upd.Key = { NumberOfUsages: this.randomUtil.getInt(0, itemDetails._props.MaximumNumberOfUsage - 1), }; return; } // Randomise items that use resources (e.g. fuel) if (itemDetails._props.MaxResource > 0) { const resourceMax = itemDetails._props.MaxResource; const resourceCurrent = this.randomUtil.getInt(1, itemDetails._props.MaxResource); itemToAdjust.upd.Resource = { Value: resourceMax - resourceCurrent, UnitsConsumed: resourceCurrent }; } } /** * Generate a randomised current and max durabiltiy value for an armor item * @param itemDetails Item to create values for * @param equipmentDurabilityLimits Max durabiltiy percent min/max values * @returns Durability + MaxDurability values */ protected getRandomisedArmorDurabilityValues( itemDetails: ITemplateItem, equipmentDurabilityLimits: IItemDurabilityCurrentMax, ): Repairable { const maxDuraMin = equipmentDurabilityLimits.max.min / 100 * itemDetails._props.MaxDurability; const maxDuraMax = equipmentDurabilityLimits.max.max / 100 * itemDetails._props.MaxDurability; const chosenMaxDurability = this.randomUtil.getInt(maxDuraMin, maxDuraMax); const currentDuraMin = equipmentDurabilityLimits.current.min / 100 * itemDetails._props.MaxDurability; const currentDuraMax = equipmentDurabilityLimits.current.max / 100 * itemDetails._props.MaxDurability; const chosenCurrentDurability = Math.min( this.randomUtil.getInt(currentDuraMin, currentDuraMax), chosenMaxDurability, ); return { Durability: chosenCurrentDurability, MaxDurability: chosenMaxDurability }; } /** * Construct item limit record to hold max and current item count * @param limits limits as defined in config * @returns record, key: item tplId, value: current/max item count allowed */ protected initItemLimitCounter(limits: Record): Record { const itemTypeCounts: Record = {}; for (const x in limits) { itemTypeCounts[x] = { current: 0, max: limits[x] }; } return itemTypeCounts; } /** * Get the next update timestamp for fence * @returns future timestamp */ public getNextFenceUpdateTimestamp(): number { const time = this.timeUtil.getTimestamp(); const updateSeconds = this.getFenceRefreshTime(); return time + updateSeconds; } /** * Get fence refresh time in seconds * @returns Refresh time in seconds */ protected getFenceRefreshTime(): number { const fence = this.traderConfig.updateTime.find((x) => x.traderId === Traders.FENCE).seconds; return this.randomUtil.getInt(fence.min, fence.max); } /** * Get fence level the passed in profile has * @param pmcData Player profile * @returns FenceLevel object */ public getFenceInfo(pmcData: IPmcData): IFenceLevel { const fenceSettings = this.databaseServer.getTables().globals.config.FenceSettings; const pmcFenceInfo = pmcData.TradersInfo[fenceSettings.FenceId]; if (!pmcFenceInfo) { return fenceSettings.Levels["0"]; } const fenceLevels = (Object.keys(fenceSettings.Levels)).map((value) => Number.parseInt(value)); const minLevel = Math.min(...fenceLevels); const maxLevel = Math.max(...fenceLevels); const pmcFenceLevel = Math.floor(pmcFenceInfo.standing); if (pmcFenceLevel < minLevel) { return fenceSettings.Levels[minLevel.toString()]; } if (pmcFenceLevel > maxLevel) { return fenceSettings.Levels[maxLevel.toString()]; } return fenceSettings.Levels[pmcFenceLevel.toString()]; } /** * Remove or lower stack size of an assort from fence by id * @param assortId assort id to adjust * @param buyCount Count of items bought */ public amendOrRemoveFenceOffer(assortId: string, buyCount: number): void { let isNormalAssort = true; let fenceAssortItem = this.fenceAssort.items.find((item) => item._id === assortId); if (!fenceAssortItem) { // Not in main assorts, check secondary section fenceAssortItem = this.fenceDiscountAssort.items.find((item) => item._id === assortId); if (!fenceAssortItem) { this.logger.error(`Offer with id: ${assortId} not found`); return; } isNormalAssort = false; } // Player wants to buy whole stack, delete stack if (fenceAssortItem.upd.StackObjectsCount === buyCount) { this.deleteOffer(assortId, isNormalAssort ? this.fenceAssort.items : this.fenceDiscountAssort.items); return; } // Adjust stack size fenceAssortItem.upd.StackObjectsCount -= buyCount; } protected deleteOffer(assortId: string, assorts: Item[]): void { // Assort could have child items, remove those too const itemWithChildrenToRemove = this.itemHelper.findAndReturnChildrenAsItems(assorts, assortId); for (const itemToRemove of itemWithChildrenToRemove) { let indexToRemove = assorts.findIndex((item) => item._id === itemToRemove._id); // No offer found in main assort, check discount items if (indexToRemove === -1) { indexToRemove = this.fenceDiscountAssort.items.findIndex((item) => item._id === itemToRemove._id); this.fenceDiscountAssort.items.splice(indexToRemove, 1); if (indexToRemove === -1) { this.logger.warning( `unable to remove fence assort item: ${itemToRemove._id} tpl: ${itemToRemove._tpl}`, ); } return; } // Remove offer from assort assorts.splice(indexToRemove, 1); } } }