Server/project/src/helpers/ItemHelper.ts

1385 lines
48 KiB
TypeScript

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 { 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";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
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 { HashUtil } from "@spt-aki/utils/HashUtil";
import { JsonUtil } from "@spt-aki/utils/JsonUtil";
import { MathUtil } from "@spt-aki/utils/MathUtil";
import { ObjectId } from "@spt-aki/utils/ObjectId";
import { ProbabilityObject, ProbabilityObjectArray, RandomUtil } from "@spt-aki/utils/RandomUtil";
@injectable()
export class ItemHelper
{
protected readonly defaultInvalidBaseTypes: string[] = [
BaseClasses.LOOT_CONTAINER,
BaseClasses.MOB_CONTAINER,
BaseClasses.STASH,
BaseClasses.SORTING_TABLE,
BaseClasses.INVENTORY,
BaseClasses.STATIONARY_CONTAINER,
BaseClasses.POCKETS,
];
constructor(
@inject("WinstonLogger") protected logger: ILogger,
@inject("HashUtil") protected hashUtil: HashUtil,
@inject("JsonUtil") protected jsonUtil: JsonUtil,
@inject("RandomUtil") protected randomUtil: RandomUtil,
@inject("ObjectId") protected objectId: ObjectId,
@inject("MathUtil") protected mathUtil: MathUtil,
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
@inject("HandbookHelper") protected handbookHelper: HandbookHelper,
@inject("ItemBaseClassService") protected itemBaseClassService: ItemBaseClassService,
@inject("ItemFilterService") protected itemFilterService: ItemFilterService,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("LocaleService") protected localeService: LocaleService,
)
{}
/**
* 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
* @returns boolean; true for items that may be in player possession and not quest items
*/
public isValidItem(tpl: string, invalidBaseTypes: string[] = null): boolean
{
if (invalidBaseTypes === null)
{
invalidBaseTypes = this.defaultInvalidBaseTypes;
}
const itemDetails = this.getItem(tpl);
if (!itemDetails[0])
{
return false;
}
// Is item valid
return !itemDetails[1]._props.QuestItem
&& itemDetails[1]._type === "Item"
&& invalidBaseTypes.every((x) => !this.isOfBaseclass(tpl, x))
&& this.getItemPrice(tpl) > 0
&& !this.itemFilterService.isItemBlacklisted(tpl);
}
/**
* Check if the tpl / template Id provided is a descendent of the baseclass
*
* @param {string} tpl the item template id to check
* @param {string} baseClassTpl the baseclass to check for
* @return {boolean} is the tpl a descendent?
*/
public isOfBaseclass(tpl: string, baseClassTpl: string): boolean
{
return this.itemBaseClassService.itemHasBaseClass(tpl, [baseClassTpl]);
}
/**
* Check if item has any of the supplied base classes
* @param tpl Item to check base classes of
* @param baseClassTpls base classes to check for
* @returns true if any supplied base classes match
*/
public isOfBaseclasses(tpl: string, baseClassTpls: string[]): boolean
{
return this.itemBaseClassService.itemHasBaseClass(tpl, baseClassTpls);
}
/**
* Does the provided item have the chance to require soft armor inserts
* Only applies to helmets/vest/armors.
* Not all head gear needs them
* @param itemTpl item to check
* @returns Does item have the possibility ot need soft inserts
*/
public armorItemCanHoldMods(itemTpl: string): boolean
{
return this.isOfBaseclasses(itemTpl, [BaseClasses.HEADWEAR, BaseClasses.VEST, BaseClasses.ARMOR]);
}
/**
* Does the provided item tpl require soft inserts to become a valid armor item
* @param itemTpl Item tpl to check
* @returns True if it needs armor inserts
*/
public itemRequiresSoftInserts(itemTpl: string): boolean
{
// not a slot that takes soft-inserts
if (!this.armorItemCanHoldMods(itemTpl))
{
return false;
}
// Check is an item
const itemDbDetails = this.getItem(itemTpl);
if (!itemDbDetails[0])
{
return false;
}
// Has no slots
if (!(itemDbDetails[1]._props.Slots ?? []).length)
{
return false;
}
// Check if item has slots that match soft insert name ids
const softInsertSlotIds = ["groin", "soft_armor_back", "soft_armor_front", "soft_armor_left", "soft_armor_right", "shoulder_l", "shoulder_r", "collar"];
if (itemDbDetails[1]._props.Slots.find(slot => softInsertSlotIds.includes(slot._name.toLowerCase())))
{
return true;
}
// Also classified as BUILT_IN_INSERTS
const helmetInsertSlotIds = ["helmet_top", "helmet_back", "helmet_ears"]
if (itemDbDetails[1]._props.Slots.find(slot => helmetInsertSlotIds.includes(slot._name.toLowerCase())))
{
return true;
}
return false;
}
/**
* Returns the item price based on the handbook or as a fallback from the prices.json if the item is not
* found in the handbook. If the price can't be found at all return 0
* @param tpl Item to look price up of
* @returns Price in roubles
*/
public getItemPrice(tpl: string): number
{
const handbookPrice = this.getStaticItemPrice(tpl);
if (handbookPrice >= 1)
{
return handbookPrice;
}
const dynamicPrice = this.getDynamicItemPrice(tpl);
if (dynamicPrice)
{
return dynamicPrice;
}
return 0;
}
/**
* Returns the item price based on the handbook or as a fallback from the prices.json if the item is not
* found in the handbook. If the price can't be found at all return 0
* @param tpl Item to look price up of
* @returns Price in roubles
*/
public getItemMaxPrice(tpl: string): number
{
const staticPrice = this.getStaticItemPrice(tpl);
const dynamicPrice = this.getDynamicItemPrice(tpl);
return Math.max(staticPrice, dynamicPrice);
}
/**
* Get the static (handbook) price in roubles for an item by tpl
* @param tpl Items tpl id to look up price
* @returns Price in roubles (0 if not found)
*/
public getStaticItemPrice(tpl: string): number
{
const handbookPrice = this.handbookHelper.getTemplatePrice(tpl);
if (handbookPrice >= 1)
{
return handbookPrice;
}
return 0;
}
/**
* Get the dynamic (flea) price in roubles for an item by tpl
* @param tpl Items tpl id to look up price
* @returns Price in roubles (undefined if not found)
*/
public getDynamicItemPrice(tpl: string): number
{
const dynamicPrice = this.databaseServer.getTables().templates.prices[tpl];
if (dynamicPrice)
{
return dynamicPrice;
}
return 0;
}
/**
* Update items upd.StackObjectsCount to be 1 if its upd is missing or StackObjectsCount is undefined
* @param item Item to update
* @returns Fixed item
*/
public fixItemStackCount(item: Item): Item
{
if (item.upd === undefined)
{
item.upd = { StackObjectsCount: 1 };
}
if (item.upd.StackObjectsCount === undefined)
{
item.upd.StackObjectsCount = 1;
}
return item;
}
/**
* Get cloned copy of all item data from items.json
* @returns array of ITemplateItem objects
*/
public getItems(): ITemplateItem[]
{
return this.jsonUtil.clone(Object.values(this.databaseServer.getTables().templates.items));
}
/**
* Gets item data from items.json
* @param tpl items template id to look up
* @returns bool - is valid + template item object as array
*/
public getItem(tpl: string): [boolean, ITemplateItem]
{
// -> Gets item from <input: _tpl>
if (tpl in this.databaseServer.getTables().templates.items)
{
return [true, this.databaseServer.getTables().templates.items[tpl]];
}
return [false, undefined];
}
public isItemInDb(tpl: string): boolean
{
const itemDetails = this.getItem(tpl);
return itemDetails[0];
}
/**
* get normalized value (0-1) based on item condition
* @param item
* @returns number between 0 and 1
*/
public getItemQualityModifier(item: Item): number
{
// Default to 100%
let result = 1;
if (item.upd)
{
const medkit = (item.upd.MedKit) ? item.upd.MedKit : null;
const repairable = (item.upd.Repairable) ? item.upd.Repairable : null;
const foodDrink = (item.upd.FoodDrink) ? item.upd.FoodDrink : null;
const key = (item.upd.Key) ? item.upd.Key : null;
const resource = (item.upd.Resource) ? item.upd.Resource : null;
const repairKit = (item.upd.RepairKit) ? item.upd.RepairKit : null;
const itemDetails = this.getItem(item._tpl)[1];
if (medkit)
{
// Meds
result = medkit.HpResource / itemDetails._props.MaxHpResource;
}
else if (repairable)
{
result = this.getRepairableItemQualityValue(itemDetails, repairable, item);
}
else if (foodDrink)
{
// food & drink
result = foodDrink.HpPercent / itemDetails._props.MaxResource;
}
else if (key && key.NumberOfUsages > 0)
{
// keys - keys count upwards, not down like everything else
const maxNumOfUsages = itemDetails._props.MaximumNumberOfUsage;
result = (maxNumOfUsages - key.NumberOfUsages) / maxNumOfUsages;
}
else if (resource && resource.UnitsConsumed > 0)
{
// Things like fuel tank
result = resource.Value / itemDetails._props.MaxResource;
}
else if (repairKit)
{
// Repair kits
result = repairKit.Resource / itemDetails._props.MaxRepairResource;
}
if (result === 0)
{
// make item non-zero but still very low
result = 0.01;
}
}
return result;
}
/**
* Get a quality value based on a repairable items (weapon/armor) current state between current and max durability
* @param itemDetails Db details for item we want quality value for
* @param repairable Repairable properties
* @param item Item quality value is for
* @returns A number between 0 and 1
*/
protected getRepairableItemQualityValue(itemDetails: ITemplateItem, repairable: Repairable, item: Item): number
{
// Edge case, max durability is below durability
if (repairable.Durability > repairable.MaxDurability)
{
this.logger.warning(
`Max durability: ${repairable.MaxDurability} for item id: ${item._id} was below Durability: ${repairable.Durability}, adjusting values to match`,
);
repairable.MaxDurability = repairable.Durability;
}
// Armor
if (itemDetails._props.armorClass)
{
return repairable.Durability / itemDetails._props.MaxDurability;
}
// Weapon
// Get max dura from props, if it isnt there use repairable max dura value
const maxDurability = (itemDetails._props.MaxDurability)
? itemDetails._props.MaxDurability
: repairable.MaxDurability;
const durability = repairable.Durability / maxDurability;
if (!durability)
{
this.logger.error(this.localisationService.getText("item-durability_value_invalid_use_default", item._tpl));
return 1;
}
return Math.sqrt(durability);
}
/**
* Recursive function that looks at every item from parameter and gets their childrens Ids + includes parent item in results
* @param items Array of items (item + possible children)
* @param baseItemId Parent items id
* @returns an array of strings
*/
public findAndReturnChildrenByItems(items: Item[], baseItemId: string): string[]
{
const list: string[] = [];
for (const childitem of items)
{
if (childitem.parentId === baseItemId)
{
list.push(...this.findAndReturnChildrenByItems(items, childitem._id));
}
}
list.push(baseItemId); // Required, push original item id onto array
return list;
}
/**
* A variant of findAndReturnChildren where the output is list of item objects instead of their ids.
* @param items Array of items (item + possible children)
* @param baseItemId Parent items id
* @param modsOnly Include only mod items, exclude items stored inside root item
* @returns An array of Item objects
*/
public findAndReturnChildrenAsItems(items: Item[], baseItemId: string, modsOnly = false): Item[]
{
const list: Item[] = [];
for (const childItem of items)
{
// Include itself
if (childItem._id === baseItemId)
{
list.unshift(childItem);
continue;
}
// Is stored in parent and disallowed
if (modsOnly && childItem.location)
{
continue;
}
// Items parentid matches root item AND returned items doesnt contain current child
if (childItem.parentId === baseItemId && !list.find((item) => childItem._id === item._id))
{
list.push(...this.findAndReturnChildrenAsItems(items, childItem._id));
}
}
return list;
}
/**
* Find children of the item in a given assort (weapons parts for example, need recursive loop function)
* @param itemIdToFind Template id of item to check for
* @param assort Array of items to check in
* @returns Array of children of requested item
*/
public findAndReturnChildrenByAssort(itemIdToFind: string, assort: Item[]): Item[]
{
let list: Item[] = [];
for (const itemFromAssort of assort)
{
if (itemFromAssort.parentId === itemIdToFind
&& !list.find((item) => itemFromAssort._id === item._id))
{
list.push(itemFromAssort);
list = list.concat(this.findAndReturnChildrenByAssort(itemFromAssort._id, assort));
}
}
return list;
}
/**
* Check if the passed in item has buy count restrictions
* @param itemToCheck Item to check
* @returns true if it has buy restrictions
*/
public hasBuyRestrictions(itemToCheck: Item): boolean
{
if (itemToCheck.upd?.BuyRestrictionCurrent !== undefined && itemToCheck.upd?.BuyRestrictionMax !== undefined)
{
return true;
}
return false;
}
/**
* is the passed in template id a dog tag
* @param tpl Template id to check
* @returns true if it is a dogtag
*/
public isDogtag(tpl: string): boolean
{
return tpl === BaseClasses.DOG_TAG_BEAR || tpl === BaseClasses.DOG_TAG_USEC;
}
/**
* Gets the identifier for a child using slotId, locationX and locationY.
* @param item
* @returns "slotId OR slotid,locationX,locationY"
*/
public getChildId(item: Item): string
{
if (!("location" in item))
{
return item.slotId;
}
return `${item.slotId},${(item.location as Location).x},${(item.location as Location).y}`;
}
/**
* Can the passed in item be stacked
* @param tpl item to check
* @returns true if it can be stacked
*/
public isItemTplStackable(tpl: string): boolean
{
const item = this.databaseServer.getTables().templates.items[tpl];
if (!item)
{
return undefined;
}
return item._props.StackMaxSize > 1;
}
/**
* split item stack if it exceeds its items StackMaxSize property
* @param itemToSplit Item to split into smaller stacks
* @returns Array of split items
*/
public splitStack(itemToSplit: Item): Item[]
{
if (!(itemToSplit?.upd?.StackObjectsCount != null))
{
return [itemToSplit];
}
const maxStackSize = this.databaseServer.getTables().templates.items[itemToSplit._tpl]._props.StackMaxSize;
let remainingCount = itemToSplit.upd.StackObjectsCount;
const stacks: Item[] = [];
// If the current count is already equal or less than the max
// then just return the item as is.
if (remainingCount <= maxStackSize)
{
stacks.push(this.jsonUtil.clone(itemToSplit));
return stacks;
}
while (remainingCount)
{
const amount = Math.min(remainingCount, maxStackSize);
const newStack = this.jsonUtil.clone(itemToSplit);
newStack._id = this.hashUtil.generate();
newStack.upd.StackObjectsCount = amount;
remainingCount -= amount;
stacks.push(newStack);
}
return stacks;
}
/**
* Find Barter items from array of items
* @param {string} by tpl or id
* @param {Item[]} itemsToSearch Array of items to iterate over
* @param {string} desiredBarterItemIds
* @returns Array of Item objects
*/
public findBarterItems(by: "tpl" | "id", itemsToSearch: Item[], desiredBarterItemIds: string | string[]): Item[]
{
// Find required items to take after buying (handles multiple items)
const desiredBarterIds = typeof desiredBarterItemIds === "string"
? [desiredBarterItemIds]
: desiredBarterItemIds;
const matchingItems: Item[] = [];
for (const barterId of desiredBarterIds)
{
const filterResult = itemsToSearch.filter((item) =>
{
return by === "tpl"
? (item._tpl === barterId)
: (item._id === barterId);
});
matchingItems.push(...filterResult);
}
if (matchingItems.length === 0)
{
this.logger.warning(`No items found for barter Id: ${desiredBarterIds}`);
}
return matchingItems;
}
/**
* Regenerate all guids with new ids, exceptions are for items that cannot be altered (e.g. stash/sorting table)
* @param pmcData Player profile
* @param items Items to adjust ID values of
* @param insuredItems insured items to not replace ids for
* @param fastPanel
* @returns Item[]
*/
public replaceIDs(pmcData: IPmcData, items: Item[], insuredItems: InsuredItem[] = null, fastPanel = null): Item[]
{
// replace bsg shit long ID with proper one
let serialisedInventory = this.jsonUtil.serialize(items);
for (const item of items)
{
if (pmcData !== null)
{
// Insured items shouldn't be renamed
// only works for pmcs.
if (insuredItems?.find((insuredItem) => insuredItem.itemId === item._id))
{
continue;
}
// Do not replace important ID's
if (
item._id === pmcData.Inventory.equipment
|| item._id === pmcData.Inventory.questRaidItems
|| item._id === pmcData.Inventory.questStashItems
|| item._id === pmcData.Inventory.sortingTable
|| item._id === pmcData.Inventory.stash
)
{
continue;
}
}
// replace id
const oldId = item._id;
const newId = this.hashUtil.generate();
serialisedInventory = serialisedInventory.replace(new RegExp(oldId, "g"), newId);
// Also replace in quick slot if the old ID exists.
if (fastPanel !== null)
{
for (const itemSlot in fastPanel)
{
if (fastPanel[itemSlot] === oldId)
{
fastPanel[itemSlot] = fastPanel[itemSlot].replace(new RegExp(oldId, "g"), newId);
}
}
}
}
items = this.jsonUtil.deserialize(serialisedInventory);
// fix duplicate id's
const dupes: Record<string, number> = {};
const newParents: Record<string, Item[]> = {};
const childrenMapping = {};
const oldToNewIds: Record<string, string[]> = {};
// Finding duplicate IDs involves scanning the item three times.
// First scan - Check which ids are duplicated.
// Second scan - Map parents to items.
// Third scan - Resolve IDs.
for (const item of items)
{
dupes[item._id] = (dupes[item._id] || 0) + 1;
}
for (const item of items)
{
// register the parents
if (dupes[item._id] > 1)
{
const newId = this.hashUtil.generate();
newParents[item.parentId] = newParents[item.parentId] || [];
newParents[item.parentId].push(item);
oldToNewIds[item._id] = oldToNewIds[item._id] || [];
oldToNewIds[item._id].push(newId);
}
}
for (const item of items)
{
if (dupes[item._id] > 1)
{
const oldId = item._id;
const newId = oldToNewIds[oldId].splice(0, 1)[0];
item._id = newId;
// Extract one of the children that's also duplicated.
if (oldId in newParents && newParents[oldId].length > 0)
{
childrenMapping[newId] = {};
for (const childIndex in newParents[oldId])
{
// Make sure we haven't already assigned another duplicate child of
// same slot and location to this parent.
const childId = this.getChildId(newParents[oldId][childIndex]);
if (!(childId in childrenMapping[newId]))
{
childrenMapping[newId][childId] = 1;
newParents[oldId][childIndex].parentId = newId;
// Some very fucking sketchy stuff on this childIndex
// No clue wth was that childIndex supposed to be, but its not
newParents[oldId].splice(Number.parseInt(childIndex), 1);
}
}
}
}
}
return items;
}
/**
* WARNING, SLOW. Recursively loop down through an items hierarchy to see if any of the ids match the supplied list, return true if any do
* @param {string} tpl Items tpl to check parents of
* @param {Array} tplsToCheck Tpl values to check if parents of item match
* @returns boolean Match found
*/
public doesItemOrParentsIdMatch(tpl: string, tplsToCheck: string[]): boolean
{
const itemDetails = this.getItem(tpl);
const itemExists = itemDetails[0];
const item = itemDetails[1];
// not an item, drop out
if (!itemExists)
{
return false;
}
// no parent to check
if (!item._parent)
{
return false;
}
// Does templateId match any values in tplsToCheck array
if (tplsToCheck.includes(item._id))
{
return true;
}
// Does the items parent type exist in tplsToCheck array
if (tplsToCheck.includes(item._parent))
{
return true;
}
// check items parent with same method
return this.doesItemOrParentsIdMatch(item._parent, tplsToCheck);
}
/**
* Check if item is quest item
* @param tpl Items tpl to check quest status of
* @returns true if item is flagged as quest item
*/
public isQuestItem(tpl: string): boolean
{
const itemDetails = this.getItem(tpl);
if (itemDetails[0] && itemDetails[1]._props.QuestItem)
{
return true;
}
return false;
}
/**
* Checks to see if the item is *actually* moddable in-raid. Checks include the items existence in the database, the
* parent items existence in the database, the existence (and value) of the items RaidModdable property, and that
* the parents slot-required property exists, matches that of the item, and it's value.
*
* Note: this function does not preform any checks to see if the item and parent are *actually* related.
*
* @param item The item to be checked
* @param parent The parent of the item to be checked
* @returns True if the item is actually moddable, false if it is not, and null if the check cannot be performed.
*/
public isRaidModdable(item: Item, parent: Item): boolean | null
{
// This check requires the item to have the slotId property populated.
if (!item.slotId)
{
return null;
}
const itemTemplate = this.getItem(item._tpl);
const parentTemplate = this.getItem(parent._tpl);
// Check for RaidModdable property on the item template.
let isNotRaidModdable = false;
if (itemTemplate[0])
{
isNotRaidModdable = itemTemplate[1]?._props?.RaidModdable === false;
}
// Check to see if the slot that the item is attached to is marked as required in the parent item's template.
let isRequiredSlot = false;
if (parentTemplate[0] && parentTemplate[1]?._props?.Slots)
{
isRequiredSlot = parentTemplate[1]._props.Slots.some((slot) =>
slot._name === item.slotId && slot._required
);
}
return itemTemplate[0] && parentTemplate[0] && !(isNotRaidModdable || isRequiredSlot);
}
/**
* Retrieves the main parent item for a given attachment item.
*
* This method traverses up the hierarchy of items starting from a given `itemId`, until it finds the main parent
* item that is not an attached attachment itself. In other words, if you pass it an item id of a suppressor, it
* will traverse up the muzzle brake, barrel, upper receiver, and return the gun that the suppressor is ultimately
* attached to, even if that gun is located within multiple containers.
*
* It's important to note that traversal is expensive, so this method requires that you pass it a Map of the items
* to traverse, where the keys are the item IDs and the values are the corresponding Item objects. This alleviates
* some of the performance concerns, as it allows for quick lookups of items by ID.
*
* To generate the map:
* ```
* const itemsMap = new Map<string, Item>();
* items.forEach(item => itemsMap.set(item._id, item));
* ```
*
* @param itemId - The unique identifier of the item for which to find the main parent.
* @param itemsMap - A Map containing item IDs mapped to their corresponding Item objects for quick lookup.
* @returns The Item object representing the top-most parent of the given item, or `null` if no such parent exists.
*/
public getAttachmentMainParent(itemId: string, itemsMap: Map<string, Item>): Item | null
{
let currentItem = itemsMap.get(itemId);
while (currentItem && this.isAttachmentAttached(currentItem))
{
currentItem = itemsMap.get(currentItem.parentId);
if (!currentItem)
{
return null;
}
}
return currentItem;
}
/**
* Determines if an item is an attachment that is currently attached to it's parent item.
*
* @param item The item to check.
* @returns true if the item is attached attachment, otherwise false.
*/
public isAttachmentAttached(item: Item): boolean
{
return item.slotId !== "hideout" && item.slotId !== "main" && Number.isNaN(Number(item.slotId));
}
/**
* Get the inventory size of an item
* @param items Item with children
* @param rootItemId
* @returns ItemSize object (width and height)
*/
public getItemSize(items: Item[], rootItemId: string): ItemHelper.ItemSize
{
const rootTemplate = this.getItem(items.filter((x) => x._id === rootItemId)[0]._tpl)[1];
const width = rootTemplate._props.Width;
const height = rootTemplate._props.Height;
let sizeUp = 0;
let sizeDown = 0;
let sizeLeft = 0;
let sizeRight = 0;
let forcedUp = 0;
let forcedDown = 0;
let forcedLeft = 0;
let forcedRight = 0;
const children = this.findAndReturnChildrenAsItems(items, rootItemId);
for (const ci of children)
{
const itemTemplate = this.getItem(ci._tpl)[1];
// Calculating child ExtraSize
if (itemTemplate._props.ExtraSizeForceAdd === true)
{
forcedUp += itemTemplate._props.ExtraSizeUp;
forcedDown += itemTemplate._props.ExtraSizeDown;
forcedLeft += itemTemplate._props.ExtraSizeLeft;
forcedRight += itemTemplate._props.ExtraSizeRight;
}
else
{
sizeUp = sizeUp < itemTemplate._props.ExtraSizeUp ? itemTemplate._props.ExtraSizeUp : sizeUp;
sizeDown = sizeDown < itemTemplate._props.ExtraSizeDown ? itemTemplate._props.ExtraSizeDown : sizeDown;
sizeLeft = sizeLeft < itemTemplate._props.ExtraSizeLeft ? itemTemplate._props.ExtraSizeLeft : sizeLeft;
sizeRight = sizeRight < itemTemplate._props.ExtraSizeRight
? itemTemplate._props.ExtraSizeRight
: sizeRight;
}
}
return {
width: width + sizeLeft + sizeRight + forcedLeft + forcedRight,
height: height + sizeUp + sizeDown + forcedUp + forcedDown,
};
}
/**
* Get a random cartridge from an items Filter property
* @param item Db item template to look up Cartridge filter values from
* @returns Caliber of cartridge
*/
public getRandomCompatibleCaliberTemplateId(item: ITemplateItem): string | null
{
const cartridges = item?._props?.Cartridges[0]?._props?.filters[0]?.Filter;
if (!cartridges)
{
this.logger.warning(`Failed to find cartridge for item: ${item?._id} ${item?._name}`);
return null;
}
return this.randomUtil.getArrayValue(cartridges);
}
/**
* Add cartridges to the ammo box with correct max stack sizes
* @param ammoBox Box to add cartridges to
* @param ammoBoxDetails Item template from items db
*/
public addCartridgesToAmmoBox(ammoBox: Item[], ammoBoxDetails: ITemplateItem): void
{
const ammoBoxMaxCartridgeCount = ammoBoxDetails._props.StackSlots[0]._max_count;
const cartridgeTpl = ammoBoxDetails._props.StackSlots[0]._props.filters[0].Filter[0];
const cartridgeDetails = this.getItem(cartridgeTpl);
const cartridgeMaxStackSize = cartridgeDetails[1]._props.StackMaxSize;
// Add new stack-size-correct items to ammo box
let currentStoredCartridgeCount = 0;
// Location in ammoBox cartridges will be placed
let location = 0;
const maxPerStack = Math.min(ammoBoxMaxCartridgeCount, cartridgeMaxStackSize);
while (currentStoredCartridgeCount < ammoBoxMaxCartridgeCount)
{
const remainingSpace = ammoBoxMaxCartridgeCount - currentStoredCartridgeCount;
const cartridgeCountToAdd = (remainingSpace < maxPerStack) ? remainingSpace : maxPerStack;
// Add cartridge item into items array
ammoBox.push(this.createCartridges(ammoBox[0]._id, cartridgeTpl, cartridgeCountToAdd, location, ammoBox[0].upd?.SpawnedInSession));
currentStoredCartridgeCount += cartridgeCountToAdd;
location++;
}
}
/**
* Check if item is stored inside of a container
* @param item Item to check is inside of container
* @param desiredContainerSlotId Name of slot to check item is in e.g. SecuredContainer/Backpack
* @param items Inventory with child parent items to check
* @returns True when item is in container
*/
public itemIsInsideContainer(item: Item, desiredContainerSlotId: string, items: Item[]): boolean
{
// Get items parent
const parent = items.find((x) => x._id === item.parentId);
if (!parent)
{
// No parent, end of line, not inside container
return false;
}
if (parent.slotId === desiredContainerSlotId)
{
return true;
}
return this.itemIsInsideContainer(parent, desiredContainerSlotId, items);
}
/**
* Add child items (cartridges) to a magazine
* @param magazine Magazine to add child items to
* @param magTemplate Db template of magazine
* @param staticAmmoDist Cartridge distribution
* @param caliber Caliber of cartridge to add to magazine
* @param minSizePercent % the magazine must be filled to
*/
public fillMagazineWithRandomCartridge(
magazine: Item[],
magTemplate: ITemplateItem,
staticAmmoDist: Record<string, IStaticAmmoDetails[]>,
caliber: string = undefined,
minSizePercent = 0.25,
): void
{
// no caliber defined, choose one at random
if (!caliber)
{
caliber = this.getRandomValidCaliber(magTemplate);
}
// Edge case for the Klin pp-9, it has a typo in its ammo caliber
if (caliber === "Caliber9x18PMM")
{
caliber = "Caliber9x18PM";
}
// Chose a randomly weighted cartridge that fits
const cartridgeTpl = this.drawAmmoTpl(caliber, staticAmmoDist);
this.fillMagazineWithCartridge(magazine, magTemplate, cartridgeTpl, minSizePercent);
}
/**
* Add child items to a magazine of a specific cartridge
* @param magazineWithChildCartridges Magazine to add child items to
* @param magTemplate Db template of magazine
* @param cartridgeTpl Cartridge to add to magazine
* @param minSizePercent % the magazine must be filled to
*/
public fillMagazineWithCartridge(
magazineWithChildCartridges: Item[],
magTemplate: ITemplateItem,
cartridgeTpl: string,
minSizePercent = 0.25,
): void
{
// Get cartridge properties and max allowed stack size
const cartridgeDetails = this.getItem(cartridgeTpl);
const cartridgeMaxStackSize = cartridgeDetails[1]._props.StackMaxSize;
// Get max number of cartridges in magazine, choose random value between min/max
const magazineCartridgeMaxCount = (this.isOfBaseclass(magTemplate._id, BaseClasses.SPRING_DRIVEN_CYLINDER))
? magTemplate._props.Slots.length // Edge case for rotating grenade launcher magazine
: magTemplate._props.Cartridges[0]?._max_count;
if (!magazineCartridgeMaxCount)
{
this.logger.warning(`Magazine: ${magTemplate._id} ${magTemplate._name} lacks a Cartridges array, unable to fill magazine with ammo`);
return;
}
const desiredStackCount = this.randomUtil.getInt(
Math.round(minSizePercent * magazineCartridgeMaxCount),
magazineCartridgeMaxCount,
);
if (magazineWithChildCartridges.length > 1)
{
this.logger.warning(`Magazine ${magTemplate._name} already has cartridges defined, this may cause issues`);
}
// Loop over cartridge count and add stacks to magazine
let currentStoredCartridgeCount = 0;
let location = 0;
while (currentStoredCartridgeCount < desiredStackCount)
{
// Get stack size of cartridges
let cartridgeCountToAdd = (desiredStackCount <= cartridgeMaxStackSize)
? desiredStackCount
: cartridgeMaxStackSize;
// Ensure we don't go over the max stackcount size
const remainingSpace = desiredStackCount - currentStoredCartridgeCount;
if (cartridgeCountToAdd > remainingSpace)
{
cartridgeCountToAdd = remainingSpace;
}
// Add cartridge item object into items array
magazineWithChildCartridges.push(
this.createCartridges(
magazineWithChildCartridges[0]._id,
cartridgeTpl, cartridgeCountToAdd,
location,
magazineWithChildCartridges[0].upd?.SpawnedInSession
)
);
currentStoredCartridgeCount += cartridgeCountToAdd;
location++;
}
// Only one cartridge stack added, remove location property as its only used for 2 or more stacks
if (location === 1)
{
delete magazineWithChildCartridges[1].location;
}
}
/**
* Choose a random bullet type from the list of possible a magazine has
* @param magTemplate Magazine template from Db
* @returns Tpl of cartridge
*/
protected getRandomValidCaliber(magTemplate: ITemplateItem): string
{
const ammoTpls = magTemplate._props.Cartridges[0]._props.filters[0].Filter;
const calibers = [
...new Set(
ammoTpls.filter((x: string) => this.getItem(x)[0]).map((x: string) =>
this.getItem(x)[1]._props.Caliber
),
),
];
return this.randomUtil.drawRandomFromList(calibers)[0];
}
/**
* Chose a randomly weighted cartridge that fits
* @param caliber Desired caliber
* @param staticAmmoDist Cartridges and thier weights
* @returns Tpl of cartridge
*/
protected drawAmmoTpl(caliber: string, staticAmmoDist: Record<string, IStaticAmmoDetails[]>): string
{
const ammoArray = new ProbabilityObjectArray<string>(this.mathUtil, this.jsonUtil);
const ammos = staticAmmoDist[caliber];
if (!ammos)
{
this.logger.error(`missing caliber data for: ${caliber}`);
}
for (const icd of ammos)
{
ammoArray.push(new ProbabilityObject(icd.tpl, icd.relativeProbability));
}
return ammoArray.draw(1)[0];
}
/**
* Create a basic cartrige object
* @param parentId container cartridges will be placed in
* @param ammoTpl Cartridge to insert
* @param stackCount Count of cartridges inside parent
* @param location Location inside parent (e.g. 0, 1)
* @param foundInRaid OPTIONAL - Are cartridges found in raid (SpawnedInSession)
* @returns Item
*/
public createCartridges(parentId: string, ammoTpl: string, stackCount: number, location: number, foundInRaid = false): Item
{
return {
_id: this.objectId.generate(),
_tpl: ammoTpl,
parentId: parentId,
slotId: "cartridges",
location: location,
upd: {
StackObjectsCount: stackCount,
SpawnedInSession: foundInRaid
},
};
}
/**
* Get the size of a stack, return 1 if no stack object count property found
* @param item Item to get stack size of
* @returns size of stack
*/
public getItemStackSize(item: Item): number
{
if (item.upd?.StackObjectsCount)
{
return item.upd.StackObjectsCount;
}
return 1;
}
/**
* Get the name of an item from the locale file using the item tpl
* @param itemTpl Tpl of item to get name of
* @returns Name of item
*/
public getItemName(itemTpl: string): string
{
return this.localeService.getLocaleDb()[`${itemTpl} Name`];
}
public getItemTplsOfBaseType(desiredBaseType: string): string[]
{
return Object.values(this.databaseServer.getTables().templates.items).filter((x) =>
x._parent === desiredBaseType
).map((x) => x._id);
}
/**
* Add child slot items to an item, chooses random child item if multiple choices exist
* @param itemToAdd array with single object (root item)
* @param itemToAddTemplate Db tempalte for root item
* @param modSpawnChanceDict Optional dictionary of mod name + % chance mod will be included in item (e.g. front_plate: 100)
* @param requiredOnly Only add required mods
* @returns Item with children
*/
public addChildSlotItems(itemToAdd: Item[], itemToAddTemplate: ITemplateItem, modSpawnChanceDict: Record<string, number> = null, requiredOnly = false): Item[]
{
const result = itemToAdd;
const incompatibleModTpls: Set<string> = new Set();
for (const slot of itemToAddTemplate._props.Slots)
{
// If only required mods is requested, skip non-essential
if (requiredOnly && !slot._required)
{
continue;
}
// Roll chance for non-required slot mods
if (modSpawnChanceDict && !slot._required)
{
// only roll chance to not include mod if dict exists and has value for this mod type (e.g. front_plate)
const modSpawnChance = modSpawnChanceDict[slot._name.toLowerCase()];
if (modSpawnChance)
{
if (!this.randomUtil.getChance100(modSpawnChance))
{
continue;
}
}
}
const itemPool = slot._props.filters[0].Filter ?? [];
const chosenTpl = this.getCompatibleTplFromArray(itemPool, incompatibleModTpls);
if (!chosenTpl)
{
this.logger.debug(`Unable to add mod to item: ${itemToAddTemplate._id} ${itemToAddTemplate._name} slot: ${slot._name} as no compatible tpl could be found in pool of ${itemPool.length}, skipping`);
continue;
}
const modItemToAdd = {
_id: this.hashUtil.generate(),
_tpl: chosenTpl,
parentId: result[0]._id,
slotId: slot._name
};
result.push(modItemToAdd);
const modItemDbDetails = this.getItem(modItemToAdd._tpl)[1];
// Include conflicting items of newly added mod in pool to be used for next mod choice
// biome-ignore lint/complexity/noForEach: <explanation>
modItemDbDetails._props.ConflictingItems.forEach(incompatibleModTpls.add, incompatibleModTpls);
}
return result;
}
/**
* Get a compatible tpl from the array provided where it is not found in the provided incompatible mod tpls parameter
* @param possibleTpls Tpls to randomply choose from
* @param incompatibleModTpls Incompatible tpls to not allow
* @returns Chosen tpl or null
*/
public getCompatibleTplFromArray(possibleTpls: string[], incompatibleModTpls: Set<string>): string
{
if (possibleTpls.length === 0)
{
return null;
}
let chosenTpl = null;
let count = 0;
while (!chosenTpl)
{
// Loop over choosing a random tpl until one is found or count varaible reaches the same size as the possible tpls array
const tpl = this.randomUtil.getArrayValue(possibleTpls);
if (incompatibleModTpls.has(tpl))
{
// Incompatible tpl was chosen, try again
count++
if (count >= possibleTpls.length)
{
return null;
}
continue;
}
chosenTpl = tpl;
}
return chosenTpl;
}
/**
* Is the provided item._props.Slots._name property a plate slot
* @param slotName Name of slot (_name) of Items Slot array
* @returns True if its a slot that holds a removable palte
*/
public isRemovablePlateSlot(slotName: string): boolean
{
return this.getRemovablePlateSlotIds().includes(slotName.toLowerCase());
}
/**
* Get a list of slot names that hold removable plates
* @returns Array of slot ids (e.g. front_plate)
*/
public getRemovablePlateSlotIds(): string[]
{
return ["front_plate", "back_plate", "side_plate", "left_side_plate", "right_side_plate"];
}
/**
* Generate new unique ids for child items while preserving hierarchy
* @param rootItem Base/primary item
* @param itemWithChildren Primary item + children of primary item
* @returns Item array with updated IDs
*/
public reparentItemAndChildren(rootItem: Item, itemWithChildren: Item[]): Item[]
{
const oldRootId = itemWithChildren[0]._id;
const idMappings = {};
idMappings[oldRootId] = rootItem._id;
for (const mod of itemWithChildren)
{
if (idMappings[mod._id] === undefined)
{
idMappings[mod._id] = this.hashUtil.generate();
}
// Has parentId + no remapping exists for its parent
if (mod.parentId !== undefined && idMappings[mod.parentId] === undefined)
{
// Make remapping for items parentId
idMappings[mod.parentId] = this.hashUtil.generate();
}
mod._id = idMappings[mod._id];
if (mod.parentId !== undefined)
{
mod.parentId = idMappings[mod.parentId];
}
}
// Force item's details into first location of presetItems
if (itemWithChildren[0]._tpl !== rootItem._tpl)
{
this.logger.warning(`Reassigning root item from ${itemWithChildren[0]._tpl} to ${rootItem._tpl}`);
}
itemWithChildren[0] = rootItem;
return itemWithChildren;
}
/**
* Update a root items _id property value to be unique
* @param itemWithChildren Item to update root items _id property
* @param newId Optional: new id to use
*/
public remapRootItemId(itemWithChildren: Item[], newId = this.hashUtil.generate()): void
{
const rootItemExistingId = itemWithChildren[0]._id;
for (const item of itemWithChildren)
{
// Root, update id
if (item._id === rootItemExistingId)
{
item._id = newId;
continue;
}
// Child with parent of root, update
if (item.parentId === rootItemExistingId)
{
item.parentId = newId;
}
}
}
}
namespace ItemHelper
{
export interface ItemSize
{
width: number;
height: number;
}
}