From 0502257093220c44cea14fd5094045256096adb8 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 1 May 2024 20:17:09 +0000 Subject: [PATCH] Added functionality for Fence to resell items sold to him by PMCs, and fixed give command giving incomplete preset items and bugged ammo boxes (!317) Fixes https://dev.sp-tarkov.com/SPT-AKI/Issues/issues/625 Co-authored-by: clodan Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/317 Co-authored-by: Alex Co-committed-by: Alex --- project/src/di/Container.ts | 2 + .../generators/FenceBaseAssortGenerator.ts | 25 +--- .../SptCommands/GiveCommand/GiveSptCommand.ts | 61 +++++--- project/src/helpers/ItemHelper.ts | 131 +++++++++++++++++- project/src/helpers/TradeHelper.ts | 8 ++ project/src/services/FenceService.ts | 115 +++++++++++++-- project/src/utils/CompareUtil.ts | 62 +++++++++ 7 files changed, 354 insertions(+), 50 deletions(-) create mode 100644 project/src/utils/CompareUtil.ts diff --git a/project/src/di/Container.ts b/project/src/di/Container.ts index ba2c1223..e3235f1f 100644 --- a/project/src/di/Container.ts +++ b/project/src/di/Container.ts @@ -241,6 +241,7 @@ import { OnUpdateModService } from "@spt-aki/services/mod/onUpdate/OnUpdateModSe import { StaticRouterModService } from "@spt-aki/services/mod/staticRouter/StaticRouterModService"; import { App } from "@spt-aki/utils/App"; import { AsyncQueue } from "@spt-aki/utils/AsyncQueue"; +import { CompareUtil } from "@spt-aki/utils/CompareUtil"; import { DatabaseImporter } from "@spt-aki/utils/DatabaseImporter"; import { EncodingUtil } from "@spt-aki/utils/EncodingUtil"; import { HashUtil } from "@spt-aki/utils/HashUtil"; @@ -410,6 +411,7 @@ export class Container depContainer.register("HttpFileUtil", HttpFileUtil, { lifecycle: Lifecycle.Singleton }); depContainer.register("ModLoadOrder", ModLoadOrder, { lifecycle: Lifecycle.Singleton }); depContainer.register("ModTypeCheck", ModTypeCheck, { lifecycle: Lifecycle.Singleton }); + depContainer.register("CompareUtil", CompareUtil, { lifecycle: Lifecycle.Singleton }); } private static registerRouters(depContainer: DependencyContainer): void diff --git a/project/src/generators/FenceBaseAssortGenerator.ts b/project/src/generators/FenceBaseAssortGenerator.ts index a1e92070..25a3cf3d 100644 --- a/project/src/generators/FenceBaseAssortGenerator.ts +++ b/project/src/generators/FenceBaseAssortGenerator.ts @@ -14,6 +14,7 @@ import { ITraderConfig } from "@spt-aki/models/spt/config/ITraderConfig"; import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; import { ConfigServer } from "@spt-aki/servers/ConfigServer"; import { DatabaseServer } from "@spt-aki/servers/DatabaseServer"; +import { FenceService } from "@spt-aki/services/FenceService"; import { ItemFilterService } from "@spt-aki/services/ItemFilterService"; import { SeasonalEventService } from "@spt-aki/services/SeasonalEventService"; import { HashUtil } from "@spt-aki/utils/HashUtil"; @@ -35,6 +36,7 @@ export class FenceBaseAssortGenerator @inject("ItemFilterService") protected itemFilterService: ItemFilterService, @inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService, @inject("ConfigServer") protected configServer: ConfigServer, + @inject("FenceService") protected fenceService: FenceService, ) { this.traderConfig = this.configServer.getConfig(ConfigTypes.TRADER); @@ -123,7 +125,7 @@ export class FenceBaseAssortGenerator // Create barter scheme (price) const barterSchemeToAdd: IBarterScheme = { - count: Math.round(this.getItemPrice(rootItemDb._id, itemWithChildrenToAdd)), + count: Math.round(this.fenceService.getItemPrice(rootItemDb._id, itemWithChildrenToAdd)), _tpl: Money.ROUBLES, }; @@ -235,27 +237,6 @@ export class FenceBaseAssortGenerator return null; } - protected getItemPrice(itemTpl: string, items: Item[]): number - { - return this.itemHelper.isOfBaseclass(itemTpl, BaseClasses.AMMO_BOX) - ? this.getAmmoBoxPrice(items) * this.traderConfig.fence.itemPriceMult - : this.handbookHelper.getTemplatePrice(itemTpl) * this.traderConfig.fence.itemPriceMult; - } - - protected getAmmoBoxPrice(items: Item[]): number - { - let total = 0; - for (const item of items) - { - if (this.itemHelper.isOfBaseclass(item._tpl, BaseClasses.AMMO)) - { - total += this.handbookHelper.getTemplatePrice(item._tpl) * (item.upd.StackObjectsCount ?? 1); - } - } - - return total; - } - /** * Add soft inserts + armor plates to an armor * @param armor Armor item array to add mods into diff --git a/project/src/helpers/Dialogue/Commando/SptCommands/GiveCommand/GiveSptCommand.ts b/project/src/helpers/Dialogue/Commando/SptCommands/GiveCommand/GiveSptCommand.ts index 199f82e3..06624375 100644 --- a/project/src/helpers/Dialogue/Commando/SptCommands/GiveCommand/GiveSptCommand.ts +++ b/project/src/helpers/Dialogue/Commando/SptCommands/GiveCommand/GiveSptCommand.ts @@ -208,7 +208,11 @@ export class GiveSptCommand implements ISptCommand } const itemsToSend: Item[] = []; - if (this.itemHelper.isOfBaseclass(checkedItem[1]._id, BaseClasses.WEAPON)) + if ( + this.itemHelper.isOfBaseclass(checkedItem[1]._id, BaseClasses.WEAPON) + || this.itemHelper.isOfBaseclass(checkedItem[1]._id, BaseClasses.ARMOR) + || this.itemHelper.isOfBaseclass(checkedItem[1]._id, BaseClasses.VEST) + ) { const preset = this.presetHelper.getDefaultPreset(checkedItem[1]._id); if (!preset) @@ -220,37 +224,58 @@ export class GiveSptCommand implements ISptCommand ); return request.dialogId; } - itemsToSend.push(...this.jsonUtil.clone(preset._items)); + for (let i = 0; i < quantity; i++) + { + let items = this.jsonUtil.clone(preset._items); + items = this.itemHelper.replaceIDs(items); + itemsToSend.push(...items); + } } else if (this.itemHelper.isOfBaseclass(checkedItem[1]._id, BaseClasses.AMMO_BOX)) { - for (let i = 0; i < +quantity; i++) + for (let i = 0; i < quantity; i++) { const ammoBoxArray: Item[] = []; ammoBoxArray.push({ _id: this.hashUtil.generate(), _tpl: checkedItem[1]._id }); - this.itemHelper.addCartridgesToAmmoBox(ammoBoxArray, checkedItem[1]); + // DO NOT generate the ammo box cartridges, the mail service does it for us! :) + // this.itemHelper.addCartridgesToAmmoBox(ammoBoxArray, checkedItem[1]); itemsToSend.push(...ammoBoxArray); } } else { - const item: Item = { - _id: this.hashUtil.generate(), - _tpl: checkedItem[1]._id, - upd: { StackObjectsCount: +quantity, SpawnedInSession: true }, - }; - try + if (checkedItem[1]._props.StackMaxSize === 1) { - itemsToSend.push(...this.itemHelper.splitStack(item)); + for (let i = 0; i < quantity; i++) + { + itemsToSend.push({ + _id: this.hashUtil.generate(), + _tpl: checkedItem[1]._id, + upd: this.itemHelper.generateUpdForItem(checkedItem[1]), + }); + } } - catch + else { - this.mailSendService.sendUserMessageToPlayer( - sessionId, - commandHandler, - "Too many items requested. Please lower the amount and try again.", - ); - return request.dialogId; + const item: Item = { + _id: this.hashUtil.generate(), + _tpl: checkedItem[1]._id, + upd: this.itemHelper.generateUpdForItem(checkedItem[1]), + }; + item.upd.StackObjectsCount = quantity; + try + { + itemsToSend.push(...this.itemHelper.splitStack(item)); + } + catch + { + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + "Too many items requested. Please lower the amount and try again.", + ); + return request.dialogId; + } } } diff --git a/project/src/helpers/ItemHelper.ts b/project/src/helpers/ItemHelper.ts index 481780f0..19f572dc 100644 --- a/project/src/helpers/ItemHelper.ts +++ b/project/src/helpers/ItemHelper.ts @@ -3,7 +3,7 @@ import { inject, injectable } from "tsyringe"; import { HandbookHelper } from "@spt-aki/helpers/HandbookHelper"; import { IPmcData } from "@spt-aki/models/eft/common/IPmcData"; import { InsuredItem } from "@spt-aki/models/eft/common/tables/IBotBase"; -import { Item, Location, Repairable } from "@spt-aki/models/eft/common/tables/IItem"; +import { Item, Location, Repairable, Upd } from "@spt-aki/models/eft/common/tables/IItem"; import { IStaticAmmoDetails } from "@spt-aki/models/eft/common/tables/ILootBase"; import { ITemplateItem } from "@spt-aki/models/eft/common/tables/ITemplateItem"; import { BaseClasses } from "@spt-aki/models/enums/BaseClasses"; @@ -14,6 +14,7 @@ import { ItemBaseClassService } from "@spt-aki/services/ItemBaseClassService"; import { ItemFilterService } from "@spt-aki/services/ItemFilterService"; import { LocaleService } from "@spt-aki/services/LocaleService"; import { LocalisationService } from "@spt-aki/services/LocalisationService"; +import { CompareUtil } from "@spt-aki/utils/CompareUtil"; import { HashUtil } from "@spt-aki/utils/HashUtil"; import { JsonUtil } from "@spt-aki/utils/JsonUtil"; import { MathUtil } from "@spt-aki/utils/MathUtil"; @@ -46,9 +47,137 @@ export class ItemHelper @inject("ItemFilterService") protected itemFilterService: ItemFilterService, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("LocaleService") protected localeService: LocaleService, + @inject("CompareUtil") protected compareUtil: CompareUtil, ) {} + /** + * This method will compare two items (with all its children) and see if the are equivalent. + * This method will NOT compare IDs on the items + * @param item1 first item with all its children to compare + * @param item2 second item with all its children to compare + * @param compareUpdProperties Upd properties to compare between the items + * @returns true if they are the same, false if they arent + */ + public isSameItems(item1: Item[], item2: Item[], compareUpdProperties?: Set): boolean + { + if (item1.length !== item2.length) + { + return false; + } + for (const itemOf1 of item1) + { + const itemOf2 = item2.find((i2) => i2._tpl === itemOf1._tpl); + if (itemOf2 === undefined) + { + return false; + } + if (!this.isSameItem(itemOf1, itemOf2, compareUpdProperties)) + { + return false; + } + } + return true; + } + + /** + * This method will compare two items and see if the are equivalent. + * This method will NOT compare IDs on the items + * @param item1 first item to compare + * @param item2 second item to compare + * @param compareUpdProperties Upd properties to compare between the items + * @returns true if they are the same, false if they arent + */ + public isSameItem(item1: Item, item2: Item, compareUpdProperties?: Set): boolean + { + if (item1._tpl !== item2._tpl) + { + return false; + } + + if (compareUpdProperties) + { + return Array.from(compareUpdProperties.values()).every((p) => + this.compareUtil.recursiveCompare(item1.upd?.[p], item2.upd?.[p]) + ); + } + + return this.compareUtil.recursiveCompare(item1.upd, item2.upd); + } + + /** + * Helper method to generate a Upd based on a template + * @param itemTemplate the item template to generate a Upd for + * @returns A Upd with all the default properties set + */ + public generateUpdForItem(itemTemplate: ITemplateItem): Upd + { + const itemProperties: Upd = {}; + + // armors, etc + if (itemTemplate._props.MaxDurability) + { + itemProperties.Repairable = { + Durability: itemTemplate._props.MaxDurability, + MaxDurability: itemTemplate._props.MaxDurability, + }; + } + + if (itemTemplate._props.HasHinge) + { + itemProperties.Togglable = { On: true }; + } + + if (itemTemplate._props.Foldable) + { + itemProperties.Foldable = { Folded: false }; + } + + if (itemTemplate._props.weapFireType?.length) + { + if (itemTemplate._props.weapFireType.includes("fullauto")) + { + itemProperties.FireMode = { FireMode: "fullauto" }; + } + else + { + itemProperties.FireMode = { FireMode: this.randomUtil.getArrayValue(itemTemplate._props.weapFireType) }; + } + } + + if (itemTemplate._props.MaxHpResource) + { + itemProperties.MedKit = { HpResource: itemTemplate._props.MaxHpResource }; + } + + if (itemTemplate._props.MaxResource && itemTemplate._props.foodUseTime) + { + itemProperties.FoodDrink = { HpPercent: itemTemplate._props.MaxResource }; + } + + if (itemTemplate._parent === BaseClasses.FLASHLIGHT) + { + itemProperties.Light = { IsActive: false, SelectedMode: 0 }; + } + else if (itemTemplate._parent === BaseClasses.TACTICAL_COMBO) + { + itemProperties.Light = { IsActive: false, SelectedMode: 0 }; + } + + if (itemTemplate._parent === BaseClasses.NIGHTVISION) + { + itemProperties.Togglable = { On: false }; + } + + // Togglable face shield + if (itemTemplate._props.HasHinge && itemTemplate._props.FaceShieldComponent) + { + itemProperties.Togglable = { On: false }; + } + + return itemProperties; + } + /** * Checks if an id is a valid item. Valid meaning that it's an item that be stored in stash * @param {string} tpl the template id / tpl diff --git a/project/src/helpers/TradeHelper.ts b/project/src/helpers/TradeHelper.ts index 05c71d56..19ad09ce 100644 --- a/project/src/helpers/TradeHelper.ts +++ b/project/src/helpers/TradeHelper.ts @@ -274,6 +274,14 @@ export class TradeHelper this.logger.debug(`Selling: id: ${matchingItemInInventory._id} tpl: ${matchingItemInInventory._tpl}`); + if (sellRequest.tid === Traders.FENCE) + { + this.fenceService.addItemsToFenceAssort( + profileWithItemsToSell.Inventory.items, + matchingItemInInventory, + ); + } + // Also removes children this.inventoryHelper.removeItem(profileWithItemsToSell, itemToBeRemoved.id, sessionID, output); } diff --git a/project/src/services/FenceService.ts b/project/src/services/FenceService.ts index 16609722..413c6c9d 100644 --- a/project/src/services/FenceService.ts +++ b/project/src/services/FenceService.ts @@ -10,6 +10,7 @@ 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 { Money } from "@spt-aki/models/enums/Money"; import { Traders } from "@spt-aki/models/enums/Traders"; import { IItemDurabilityCurrentMax, ITraderConfig } from "@spt-aki/models/spt/config/ITraderConfig"; import { ICreateFenceAssortsResult } from "@spt-aki/models/spt/fence/ICreateFenceAssortsResult"; @@ -46,6 +47,18 @@ export class FenceService /** Desired baseline counts - Hydrated on initial assort generation as part of generateFenceAssorts() */ protected desiredAssortCounts: IFenceAssortGenerationValues; + protected fenceItemUpdCompareProperties = new Set([ + "Buff", + "Repairable", + "RecodableComponent", + "Key", + "Resource", + "MedKit", + "FoodDrink", + "Dogtag", + "RepairKit", + ]); + constructor( @inject("WinstonLogger") protected logger: ILogger, @inject("JsonUtil") protected jsonUtil: JsonUtil, @@ -142,6 +155,72 @@ export class FenceService return assort; } + /** + * Adds to fence assort a single item (with its children) + * @param items the items to add with all its childrens + * @param mainItem the most parent item of the array + */ + public addItemsToFenceAssort(items: Item[], mainItem: Item): void + { + // HUGE THANKS TO LACYWAY AND LEAVES FOR PROVIDING THIS SOLUTION FOR SPT TO IMPLEMENT!! + // Copy the item and its children + let clonedItems = this.jsonUtil.clone(this.itemHelper.findAndReturnChildrenAsItems(items, mainItem._id)); + const root = clonedItems[0]; + + const cost = this.getItemPrice(root._tpl, clonedItems); + + // Fix IDs + clonedItems = this.itemHelper.reparentItemAndChildren(root, clonedItems); + root.parentId = "hideout"; + if (root.upd?.SpawnedInSession !== undefined) + { + root.upd.SpawnedInSession = false; + } + + // Clean up the items + delete root.location; + + const createAssort: ICreateFenceAssortsResult = { sptItems: [], barter_scheme: {}, loyal_level_items: {} }; + createAssort.barter_scheme[root._id] = [[{ count: cost, _tpl: Money.ROUBLES }]]; + createAssort.sptItems.push(clonedItems); + createAssort.loyal_level_items[root._id] = 1; + + this.updateFenceAssorts(createAssort, this.fenceAssort); + } + + /** + * Calculates the overall price for an item (with all its children) + * @param itemTpl the item tpl to calculate the fence price for + * @param items the items (with its children) to calculate fence price for + * @returns the fence price of the item + */ + public getItemPrice(itemTpl: string, items: Item[]): number + { + return this.itemHelper.isOfBaseclass(itemTpl, BaseClasses.AMMO_BOX) + ? this.getAmmoBoxPrice(items) * this.traderConfig.fence.itemPriceMult + : this.handbookHelper.getTemplatePrice(itemTpl) * this.traderConfig.fence.itemPriceMult; + } + + /** + * Calculate the overall price for an ammo box, where only one item is + * the ammo box itself and every other items are the bullets in that box + * @param items the ammo box (and all its children ammo items) + * @returns the price of the ammo box + */ + protected getAmmoBoxPrice(items: Item[]): number + { + let total = 0; + for (const item of items) + { + if (this.itemHelper.isOfBaseclass(item._tpl, BaseClasses.AMMO)) + { + total += this.handbookHelper.getTemplatePrice(item._tpl) * (item.upd.StackObjectsCount ?? 1); + } + } + + return total; + } + /** * Adjust all items contained inside an assort by a multiplier * @param assort (clone)Assort that contains items with prices to adjust @@ -325,18 +404,36 @@ export class FenceService // Check if same type of item exists + its on list of item types to always stack if (existingRootItem && this.itemInPreventDupeCategoryList(newRootItem._tpl)) { - // Guard against a missing stack count - if (!existingRootItem.upd.StackObjectsCount) + const existingFullItemTree = this.itemHelper.findAndReturnChildrenAsItems( + existingFenceAssorts.items, + existingRootItem._id, + ); + if ( + this.itemHelper.isSameItems( + itemWithChildren, + existingFullItemTree, + this.fenceItemUpdCompareProperties, + ) + ) { - existingRootItem.upd.StackObjectsCount = 1; + // Guard against a missing stack count + if (!existingRootItem.upd.StackObjectsCount) + { + existingRootItem.upd.StackObjectsCount = 1; + } + + // Merge new items count into existing, dont add new loyalty/barter data as it already exists + existingRootItem.upd.StackObjectsCount += newRootItem?.upd?.StackObjectsCount ?? 1; + + continue; } - - // Merge new items count into existing, dont add new loyalty/barter data as it already exists - existingRootItem.upd.StackObjectsCount += newRootItem.upd.StackObjectsCount; - - continue; } + // if the upd doesnt exist just initialize it + if (newRootItem.upd === undefined) + { + newRootItem.upd = {}; + } // New assort to be added to existing assorts existingFenceAssorts.items.push(...itemWithChildren); existingFenceAssorts.barter_scheme[newRootItem._id] = newFenceAssorts.barter_scheme[newRootItem._id]; @@ -366,7 +463,7 @@ export class FenceService ): IGenerationAssortValues { const allRootItems = assortItems.filter((item) => item.slotId === "hideout"); - const rootPresetItems = allRootItems.filter((item) => item.upd.sptPresetId); + const rootPresetItems = allRootItems.filter((item) => item?.upd?.sptPresetId); // Get count of weapons const currentWeaponPresetCount = rootPresetItems.reduce((count, item) => diff --git a/project/src/utils/CompareUtil.ts b/project/src/utils/CompareUtil.ts new file mode 100644 index 00000000..80b32053 --- /dev/null +++ b/project/src/utils/CompareUtil.ts @@ -0,0 +1,62 @@ +import { injectable } from "tsyringe"; + +@injectable() +export class CompareUtil +{ + private static typesToCheckAgainst = new Set([ + "string", + "number", + "boolean", + "bigint", + "symbol", + "undefined", + "null", + ]); + + /** + * This function does an object comparison, equivalent to applying reflections + * and scanning for all possible properties including arrays. + * @param v1 value 1 to compare + * @param v2 value 2 to compare + * @returns true if equal, false if not + */ + public recursiveCompare(v1: any, v2: any): boolean + { + const typeOfv1 = typeof v1; + const typeOfv2 = typeof v2; + if (CompareUtil.typesToCheckAgainst.has(typeOfv1)) + { + return v1 === v2; + } + if (typeOfv1 === "object" && typeOfv2 === "object") + { + if (Array.isArray(v1)) + { + if (!Array.isArray(v2)) + { + return false; + } + const arr1 = v1 as Array; + const arr2 = v2 as Array; + if (arr1.length !== arr2.length) + { + return false; + } + return arr1.every((vOf1) => arr2.find((vOf2) => this.recursiveCompare(vOf1, vOf2))); + } + for (const propOf1 in v1) + { + if (v2[propOf1] === undefined) + { + return false; + } + return this.recursiveCompare(v1[propOf1], v2[propOf1]); + } + } + if (typeOfv1 === typeOfv2) + { + return v1 === v2; + } + throw new Error(`could not detect type match for ${typeOfv1} and ${typeOfv2}`); + } +}