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 <clodan@clodan.com>
Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/317
Co-authored-by: Alex <clodan@noreply.dev.sp-tarkov.com>
Co-committed-by: Alex <clodan@noreply.dev.sp-tarkov.com>
This commit is contained in:
Alex 2024-05-01 20:17:09 +00:00 committed by chomp
parent ae0b7f83ec
commit 0502257093
7 changed files with 354 additions and 50 deletions

View File

@ -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", HttpFileUtil, { lifecycle: Lifecycle.Singleton });
depContainer.register<ModLoadOrder>("ModLoadOrder", ModLoadOrder, { lifecycle: Lifecycle.Singleton });
depContainer.register<ModTypeCheck>("ModTypeCheck", ModTypeCheck, { lifecycle: Lifecycle.Singleton });
depContainer.register<CompareUtil>("CompareUtil", CompareUtil, { lifecycle: Lifecycle.Singleton });
}
private static registerRouters(depContainer: DependencyContainer): void

View File

@ -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

View File

@ -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,25 +224,45 @@ 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
{
if (checkedItem[1]._props.StackMaxSize === 1)
{
for (let i = 0; i < quantity; i++)
{
itemsToSend.push({
_id: this.hashUtil.generate(),
_tpl: checkedItem[1]._id,
upd: this.itemHelper.generateUpdForItem(checkedItem[1]),
});
}
}
else
{
const item: Item = {
_id: this.hashUtil.generate(),
_tpl: checkedItem[1]._id,
upd: { StackObjectsCount: +quantity, SpawnedInSession: true },
upd: this.itemHelper.generateUpdForItem(checkedItem[1]),
};
item.upd.StackObjectsCount = quantity;
try
{
itemsToSend.push(...this.itemHelper.splitStack(item));
@ -253,6 +277,7 @@ export class GiveSptCommand implements ISptCommand
return request.dialogId;
}
}
}
// Flag the items as FiR
this.itemHelper.setFoundInRaid(itemsToSend);

View File

@ -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<string>): 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<string>): 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

View File

@ -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);
}

View File

@ -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<string>([
"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
@ -324,6 +403,18 @@ 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))
{
const existingFullItemTree = this.itemHelper.findAndReturnChildrenAsItems(
existingFenceAssorts.items,
existingRootItem._id,
);
if (
this.itemHelper.isSameItems(
itemWithChildren,
existingFullItemTree,
this.fenceItemUpdCompareProperties,
)
)
{
// Guard against a missing stack count
if (!existingRootItem.upd.StackObjectsCount)
@ -332,11 +423,17 @@ export class FenceService
}
// Merge new items count into existing, dont add new loyalty/barter data as it already exists
existingRootItem.upd.StackObjectsCount += newRootItem.upd.StackObjectsCount;
existingRootItem.upd.StackObjectsCount += newRootItem?.upd?.StackObjectsCount ?? 1;
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) =>

View File

@ -0,0 +1,62 @@
import { injectable } from "tsyringe";
@injectable()
export class CompareUtil
{
private static typesToCheckAgainst = new Set<string>([
"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<any>;
const arr2 = v2 as Array<any>;
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}`);
}
}