From e915d1701973e79cbea88490514a27cd8494583a Mon Sep 17 00:00:00 2001 From: Dev Date: Fri, 9 Feb 2024 12:39:58 +0000 Subject: [PATCH] Reworked fence assort generation and partial refresh Split item generation values up, separate value for item/equipment/weapon Fixed more assorts being generated than were being deleted each partial refresh Added assort item options to discount assort config --- project/assets/configs/trader.json | 10 +- .../src/models/spt/config/ITraderConfig.ts | 2 + .../spt/fence/IFenceAssortGenerationValues.ts | 12 + project/src/services/FenceService.ts | 339 ++++++++++++------ 4 files changed, 254 insertions(+), 109 deletions(-) create mode 100644 project/src/models/spt/fence/IFenceAssortGenerationValues.ts diff --git a/project/assets/configs/trader.json b/project/assets/configs/trader.json index e8a3b355..48e9104d 100644 --- a/project/assets/configs/trader.json +++ b/project/assets/configs/trader.json @@ -49,7 +49,15 @@ "discountOptions": { "assortSize": 50, "itemPriceMult": 0.8, - "presetPriceMult": 1.2 + "presetPriceMult": 1.2, + "weaponPresetMinMax": { + "min": 12, + "max": 19 + }, + "equipmentPresetMinMax": { + "min": 8, + "max": 15 + } }, "partialRefreshTimeSeconds": 240, "partialRefreshChangePercent": 15, diff --git a/project/src/models/spt/config/ITraderConfig.ts b/project/src/models/spt/config/ITraderConfig.ts index 36834832..fc5714e1 100644 --- a/project/src/models/spt/config/ITraderConfig.ts +++ b/project/src/models/spt/config/ITraderConfig.ts @@ -60,4 +60,6 @@ export interface DiscountOptions assortSize: number; itemPriceMult: number; presetPriceMult: number; + weaponPresetMinMax: MinMax; + equipmentPresetMinMax: MinMax; } diff --git a/project/src/models/spt/fence/IFenceAssortGenerationValues.ts b/project/src/models/spt/fence/IFenceAssortGenerationValues.ts new file mode 100644 index 00000000..05e3cfd6 --- /dev/null +++ b/project/src/models/spt/fence/IFenceAssortGenerationValues.ts @@ -0,0 +1,12 @@ +export interface IFenceAssortGenerationValues +{ + normal: IGenerationAssortValues; + discount: IGenerationAssortValues; +} + +export interface IGenerationAssortValues +{ + item: number; + weaponPreset: number; + equipmentPreset: number; +} diff --git a/project/src/services/FenceService.ts b/project/src/services/FenceService.ts index a10cb541..f54ae6ee 100644 --- a/project/src/services/FenceService.ts +++ b/project/src/services/FenceService.ts @@ -13,12 +13,14 @@ 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 { 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 { ItemFilterService } from "@spt-aki/services/ItemFilterService"; import { LocalisationService } from "@spt-aki/services/LocalisationService"; -import { HashUtil } from "@spt-aki/utils/HashUtil"; import { JsonUtil } from "@spt-aki/utils/JsonUtil"; import { RandomUtil } from "@spt-aki/utils/RandomUtil"; import { TimeUtil } from "@spt-aki/utils/TimeUtil"; @@ -30,16 +32,22 @@ import { TimeUtil } from "@spt-aki/utils/TimeUtil"; @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; - protected traderConfig: ITraderConfig; - protected nextMiniRefreshTimestamp: number; + + /** Hydrated on initial assort generation as part of generateFenceAssorts() */ + protected desiredAssortCounts: IFenceAssortGenerationValues; constructor( @inject("WinstonLogger") protected logger: ILogger, - @inject("HashUtil") protected hashUtil: HashUtil, @inject("JsonUtil") protected jsonUtil: JsonUtil, @inject("TimeUtil") protected timeUtil: TimeUtil, @inject("RandomUtil") protected randomUtil: RandomUtil, @@ -47,7 +55,6 @@ export class FenceService @inject("HandbookHelper") protected handbookHelper: HandbookHelper, @inject("ItemHelper") protected itemHelper: ItemHelper, @inject("PresetHelper") protected presetHelper: PresetHelper, - @inject("ItemFilterService") protected itemFilterService: ItemFilterService, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("ConfigServer") protected configServer: ConfigServer, ) @@ -214,7 +221,7 @@ export class FenceService */ public needsPartialRefresh(): boolean { - return this.timeUtil.getTimestamp() > this.nextMiniRefreshTimestamp; + return this.timeUtil.getTimestamp() > this.nextPartialRefreshTimestamp; } /** @@ -222,40 +229,24 @@ export class FenceService */ public performPartialRefresh(): void { - let itemCountToReplace = this.getCountOfItemsToReplace(this.traderConfig.fence.assortSize); + const itemCountToReplace = this.getCountOfItemsToReplace(this.traderConfig.fence.assortSize); const discountItemCountToReplace = this.getCountOfItemsToReplace( this.traderConfig.fence.discountOptions.assortSize, ); - // Iterate x times to remove items (only remove if assort has items) - if (this.fenceAssort?.items?.length > 0) - { - const rootItems = this.fenceAssort.items.filter((item) => item.slotId === "hideout"); - for (let index = 0; index < itemCountToReplace; index++) - { - this.removeRandomItemFromAssorts(this.fenceAssort, rootItems); - } - } + // Simulate players buying items + this.deleteRandomAssorts(itemCountToReplace, this.fenceAssort); + this.deleteRandomAssorts(discountItemCountToReplace, this.fenceDiscountAssort); - // Iterate x times to remove items (only remove if assort has items) - if (this.fenceDiscountAssort?.items?.length > 0) - { - const rootItems = this.fenceDiscountAssort.items.filter((item) => item.slotId === "hideout"); - for (let index = 0; index < discountItemCountToReplace; index++) - { - this.removeRandomItemFromAssorts(this.fenceDiscountAssort, rootItems); - } - } - - itemCountToReplace = this.getCountOfItemsToGenerate(itemCountToReplace); + // Get count of what item pools need new items (item/weapon/equipment) + const itemCountsToReplace = this.getCountOfItemsToGenerate(); const newItems = this.createFenceAssortSkeleton(); - const newDiscountItems = this.createFenceAssortSkeleton(); - this.createAssorts(itemCountToReplace, newItems, 1); - this.createAssorts(discountItemCountToReplace, newDiscountItems, 2); - - // Add new items to fence assorts + 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 @@ -283,6 +274,7 @@ export class FenceService newDiscountItems.loyal_level_items[loyaltyItemKey]; } + // Reset the clock this.incrementPartialRefreshTime(); } @@ -291,7 +283,7 @@ export class FenceService */ protected incrementPartialRefreshTime(): void { - this.nextMiniRefreshTimestamp = this.timeUtil.getTimestamp() + this.nextPartialRefreshTimestamp = this.timeUtil.getTimestamp() + this.traderConfig.fence.partialRefreshTimeSeconds; } @@ -301,17 +293,97 @@ export class FenceService * @param existingItemCountToReplace count of items to generate * @returns number of items to generate */ - protected getCountOfItemsToGenerate(existingItemCountToReplace: number): number + protected getCountOfItemsToGenerate(): IFenceAssortGenerationValues { - const desiredTotalCount = this.traderConfig.fence.assortSize; - const actualTotalCount = this.fenceAssort.items.reduce((count, item) => + 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 item.slotId === "hideout" ? count + 1 : count; + return this.itemHelper.isOfBaseclass(item._tpl, BaseClasses.WEAPON) ? count + 1 : count; }, 0); - return actualTotalCount < desiredTotalCount - ? (desiredTotalCount - actualTotalCount) + existingItemCountToReplace - : existingItemCountToReplace; + // 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); + } + } } /** @@ -368,19 +440,60 @@ export class FenceService // 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.traderConfig.fence.assortSize, assorts, 1); + 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.traderConfig.fence.discountOptions.assortSize, discountAssorts, 2); + 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 @@ -400,25 +513,37 @@ export class FenceService * @param assortCount Number of assorts to generate * @param assorts object to add created assorts to */ - protected createAssorts(assortCount: number, assorts: ITraderAssort, loyaltyLevel: number): void + protected createAssorts(itemCounts: IGenerationAssortValues, assorts: ITraderAssort, loyaltyLevel: number): void { const baseFenceAssortClone = this.jsonUtil.clone(this.databaseServer.getTables().traders[Traders.FENCE].assort); - const itemTypeCounts = this.initItemLimitCounter(this.traderConfig.fence.itemTypeLimits); + const itemTypeLimitCounts = this.initItemLimitCounter(this.traderConfig.fence.itemTypeLimits); - this.addItemAssorts(assortCount, assorts, baseFenceAssortClone, itemTypeCounts, loyaltyLevel); + if (itemCounts.item > 0) + { + this.addItemAssorts(itemCounts.item, assorts, baseFenceAssortClone, itemTypeLimitCounts, loyaltyLevel); + } - // Add presets - const weaponPresetCount = this.randomUtil.getInt( - this.traderConfig.fence.weaponPresetMinMax.min, - this.traderConfig.fence.weaponPresetMinMax.max, - ); - const equipmentPresetCount = this.randomUtil.getInt( - this.traderConfig.fence.equipmentPresetMinMax.min, - this.traderConfig.fence.equipmentPresetMinMax.max, - ); - this.addPresetsToAssort(weaponPresetCount, equipmentPresetCount, assorts, baseFenceAssortClone, 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 baseFenceAssort Base data to draw from + * @param itemTypeCounts + * @param loyaltyLevel Loyalty level to set new item to + */ protected addItemAssorts( assortCount: number, assorts: ITraderAssort, @@ -502,8 +627,8 @@ export class FenceService /** * Find presets in base fence assort and add desired number to 'assorts' parameter * @param desiredWeaponPresetsCount - * @param assorts - * @param baseFenceAssort + * @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( @@ -515,63 +640,61 @@ export class FenceService ): void { let weaponPresetsAddedCount = 0; - if (desiredWeaponPresetsCount <= 0) + if (desiredWeaponPresetsCount > 0) { - return; - } - - 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), + const weaponPresetRootItems = baseFenceAssort.items.filter((item) => + item.upd?.sptPresetId && this.itemHelper.isOfBaseclass(item._tpl, BaseClasses.WEAPON) ); - - 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) + while (weaponPresetsAddedCount < desiredWeaponPresetsCount) { - if (itemPrice > priceLimitRouble) + const randomPresetRoot = this.randomUtil.getArrayValue(weaponPresetRootItems); + if (this.traderConfig.fence.blacklist.includes(randomPresetRoot._tpl)) { - // Too expensive, try again 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++; } - - // 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;