import { inject, injectable } from "tsyringe"; import { HandbookHelper } from "@spt/helpers/HandbookHelper"; import { IStaticAmmoDetails } from "@spt/models/eft/common/ILocation"; import { IPmcData } from "@spt/models/eft/common/IPmcData"; import { InsuredItem } from "@spt/models/eft/common/tables/IBotBase"; import { Item, Location, Repairable, Upd } from "@spt/models/eft/common/tables/IItem"; import { ITemplateItem } from "@spt/models/eft/common/tables/ITemplateItem"; import { BaseClasses } from "@spt/models/enums/BaseClasses"; import { EquipmentSlots } from "@spt/models/enums/EquipmentSlots"; import { ILogger } from "@spt/models/spt/utils/ILogger"; import { DatabaseServer } from "@spt/servers/DatabaseServer"; import { ItemBaseClassService } from "@spt/services/ItemBaseClassService"; import { ItemFilterService } from "@spt/services/ItemFilterService"; import { LocaleService } from "@spt/services/LocaleService"; import { LocalisationService } from "@spt/services/LocalisationService"; import { ICloner } from "@spt/utils/cloners/ICloner"; import { CompareUtil } from "@spt/utils/CompareUtil"; import { HashUtil } from "@spt/utils/HashUtil"; import { JsonUtil } from "@spt/utils/JsonUtil"; import { MathUtil } from "@spt/utils/MathUtil"; import { ObjectId } from "@spt/utils/ObjectId"; import { ProbabilityObject, ProbabilityObjectArray, RandomUtil } from "@spt/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, @inject("CompareUtil") protected compareUtil: CompareUtil, @inject("RecursiveCloner") protected cloner: ICloner, ) {} /** * 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 * @returns boolean; true for items that may be in player possession and not quest items */ public isValidItem(tpl: string, invalidBaseTypes: string[] = null): boolean { const baseTypes = invalidBaseTypes || this.defaultInvalidBaseTypes; const itemDetails = this.getItem(tpl); if (!itemDetails[0]) { return false; } return ( !itemDetails[1]._props.QuestItem && itemDetails[1]._type === "Item" && baseTypes.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 need soft/removable inserts to function * @param itemTpl Armor item * @returns True if item needs some kind of insert */ public armorItemHasRemovableOrSoftInsertSlots(itemTpl: string): boolean { if (!this.armorItemCanHoldMods(itemTpl)) { return false; } return this.armorItemHasRemovablePlateSlots(itemTpl) || this.itemRequiresSoftInserts(itemTpl); } /** * Does the pased in tpl have ability to hold removable plate items * @param itemTpl item tpl to check for plate support * @returns True when armor can hold plates */ public armorItemHasRemovablePlateSlots(itemTpl: string): boolean { const itemTemplate = this.getItem(itemTpl); const plateSlotIds = this.getRemovablePlateSlotIds(); return itemTemplate[1]._props.Slots.some((slot) => plateSlotIds.includes(slot._name.toLowerCase())); } /** * 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 softInsertIds = this.getSoftInsertSlotIds(); if (itemDbDetails[1]._props.Slots.find((slot) => softInsertIds.includes(slot._name.toLowerCase()))) { return true; } return false; } /** * Get all soft insert slot ids * @returns An array of soft insert ids (e.g. soft_armor_back, helmet_top) */ public getSoftInsertSlotIds(): string[] { return [ "groin", "groin_back", "soft_armor_back", "soft_armor_front", "soft_armor_left", "soft_armor_right", "shoulder_l", "shoulder_r", "collar", "helmet_top", "helmet_back", "helmet_eyes", "helmet_jaw", "helmet_ears", ]; } /** * Returns the items total 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 tpls item tpls to look up the price of * @returns Total price in roubles */ public getItemAndChildrenPrice(tpls: string[]): number { // Run getItemPrice for each tpl in tpls array, return sum return tpls.reduce((total, tpl) => total + this.getItemPrice(tpl), 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 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.cloner.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 if (tpl in this.databaseServer.getTables().templates.items) { return [true, this.databaseServer.getTables().templates.items[tpl]]; } return [false, undefined]; } public itemHasSlots(itemTpl: string): boolean { return this.getItem(itemTpl)[1]._props.Slots?.length > 0; } public isItemInDb(tpl: string): boolean { const itemDetails = this.getItem(tpl); return itemDetails[0]; } /** * Calcualte the average quality of an item and its children * @param items An offers item to process * @returns % quality modifer between 0 and 1 */ public getItemQualityModifierForOfferItems(items: Item[]): number { if (this.isOfBaseclass(items[0]._tpl, BaseClasses.WEAPON)) { return this.getItemQualityModifier(items[0]); } let qualityModifier = 0; for (const item of items) { qualityModifier += this.getItemQualityModifier(item); } return Math.min(qualityModifier / items.length, 1); } /** * 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 && itemDetails._props.MaximumNumberOfUsage > 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; } return 1; } /** * 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; } // Attempt to get the max durability from _props. If not available, use Repairable max durability value instead. 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 into child items of passed in parent * @param itemToSplit Item to split into smaller stacks * @returns Array of root item + children */ public splitStack(itemToSplit: Item): Item[] { if (!(itemToSplit?.upd?.StackObjectsCount != null)) { return [itemToSplit]; } const maxStackSize = this.getItem(itemToSplit._tpl)[1]._props.StackMaxSize; let remainingCount = itemToSplit.upd.StackObjectsCount; const rootAndChildren: Item[] = []; // If the current count is already equal or less than the max // return the item as is. if (remainingCount <= maxStackSize) { rootAndChildren.push(this.cloner.clone(itemToSplit)); return rootAndChildren; } while (remainingCount) { const amount = Math.min(remainingCount, maxStackSize); const newStackClone = this.cloner.clone(itemToSplit); newStackClone._id = this.hashUtil.generate(); newStackClone.upd.StackObjectsCount = amount; remainingCount -= amount; rootAndChildren.push(newStackClone); } return rootAndChildren; } /** * Turn items like money into separate stacks that adhere to max stack size * @param itemToSplit Item to split into smaller stacks * @returns */ public splitStackIntoSeparateItems(itemToSplit: Item): Item[][] { const itemTemplate = this.getItem(itemToSplit._tpl)[1]; const itemMaxStackSize = itemTemplate._props.StackMaxSize ?? 1; // item already within bounds of stack size, return it if (itemToSplit.upd?.StackObjectsCount <= itemMaxStackSize) { return [[itemToSplit]]; } // Split items stack into chunks const result: Item[][] = []; let remainingCount = itemToSplit.upd.StackObjectsCount; while (remainingCount) { const amount = Math.min(remainingCount, itemMaxStackSize); const newItemClone = this.cloner.clone(itemToSplit); newItemClone._id = this.hashUtil.generate(); newItemClone.upd.StackObjectsCount = amount; remainingCount -= amount; result.push([newItemClone]); } return result; } /** * 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, for the exception of special item types (e.g. quest, sorting table, etc.) This * function will not mutate the original items array, but will return a new array with new GUIDs. * * @param originalItems Items to adjust the IDs of * @param pmcData Player profile * @param insuredItems Insured items that should not have their IDs replaced * @param fastPanel Quick slot panel * @returns Item[] */ public replaceIDs( originalItems: Item[], pmcData: IPmcData | null = null, insuredItems: InsuredItem[] | null = null, fastPanel = null, ): Item[] { let items = this.cloner.clone(originalItems); // Deep-clone the items to avoid mutation. let serialisedInventory = this.jsonUtil.serialize(items); for (const item of items) { if (pmcData !== null) { // Insured items should not be renamed. Only works for PMCs. if (insuredItems?.find((insuredItem) => insuredItem.itemId === item._id)) { continue; } // Do not replace the IDs of specific types of items. 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 the ID of the item in the serialised inventory using a regular expression. 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 = {}; const newParents: Record = {}; const childrenMapping = {}; const oldToNewIds: Record = {}; // 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; } /** * Mark the passed in array of items as found in raid. * Modifies passed in items * @param items The list of items to mark as FiR */ public setFoundInRaid(items: Item[]): void { for (const item of items) { if (!item.upd) { item.upd = {}; } item.upd.SpawnedInSession = true; } } /** * 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. * * @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): 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 { const equipmentSlots = Object.values(EquipmentSlots).map((value) => value as string); return !( ["hideout", "main"].includes(item.slotId) || equipmentSlots.includes(item.slotId) || !Number.isNaN(Number(item.slotId)) ); } /** * Retrieves the equipment parent item for a given item. * * This method traverses up the hierarchy of items starting from a given `itemId`, until it finds the equipment * parent item. In other words, if you pass it an item id of a suppressor, it will traverse up the muzzle brake, * barrel, upper receiver, gun, nested backpack, and finally return the backpack Item that is equipped. * * 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. * * @param itemId - The unique identifier of the item for which to find the equipment parent. * @param itemsMap - A Map containing item IDs mapped to their corresponding Item objects for quick lookup. * @returns The Item object representing the equipment parent of the given item, or `null` if no such parent exists. */ public getEquipmentParent(itemId: string, itemsMap: Map): Item | null { let currentItem = itemsMap.get(itemId); const equipmentSlots = Object.values(EquipmentSlots).map((value) => value as string); while (currentItem && !equipmentSlots.includes(currentItem.slotId)) { currentItem = itemsMap.get(currentItem.parentId); if (!currentItem) { return null; } } return currentItem; } /** * 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; // Exit if ammo already exists in box if (ammoBox.find((item) => item._tpl === cartridgeTpl)) { return; } // Add new stack-size-correct items to ammo box let currentStoredCartridgeCount = 0; const maxPerStack = Math.min(ammoBoxMaxCartridgeCount, cartridgeMaxStackSize); // Find location based on Max ammo box size let location = Math.ceil(ammoBoxMaxCartridgeCount / maxPerStack) - 1; while (currentStoredCartridgeCount < ammoBoxMaxCartridgeCount) { const remainingSpace = ammoBoxMaxCartridgeCount - currentStoredCartridgeCount; const cartridgeCountToAdd = remainingSpace < maxPerStack ? remainingSpace : maxPerStack; // Add cartridge item into items array const cartridgeItemToAdd = this.createCartridges( ammoBox[0]._id, cartridgeTpl, cartridgeCountToAdd, location, ammoBox[0].upd?.SpawnedInSession, ); // In live no ammo box has the first cartridge item with a location if (location === 0) { delete cartridgeItemToAdd.location; } ammoBox.push(cartridgeItemToAdd); currentStoredCartridgeCount += cartridgeCountToAdd; location--; } } /** * Add a single stack of cartridges to the ammo box * @param ammoBox Box to add cartridges to * @param ammoBoxDetails Item template from items db */ public addSingleStackCartridgesToAmmoBox(ammoBox: Item[], ammoBoxDetails: ITemplateItem): void { const ammoBoxMaxCartridgeCount = ammoBoxDetails._props.StackSlots[0]._max_count; const cartridgeTpl = ammoBoxDetails._props.StackSlots[0]._props.filters[0].Filter[0]; ammoBox.push( this.createCartridges( ammoBox[0]._id, cartridgeTpl, ammoBoxMaxCartridgeCount, 0, ammoBox[0].upd?.SpawnedInSession, ), ); } /** * 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 * @param defaultCartridgeTpl Cartridge to use when none found * @param weapon Weapon the magazine will be used for (if passed in uses Chamber as whitelist) */ public fillMagazineWithRandomCartridge( magazine: Item[], magTemplate: ITemplateItem, staticAmmoDist: Record, caliber: string = undefined, minSizePercent = 0.25, defaultCartridgeTpl?: string, weapon?: ITemplateItem, ): void { let chosenCaliber = caliber || this.getRandomValidCaliber(magTemplate); // Edge case for the Klin pp-9, it has a typo in its ammo caliber if (chosenCaliber === "Caliber9x18PMM") { chosenCaliber = "Caliber9x18PM"; } // Chose a randomly weighted cartridge that fits const cartridgeTpl = this.drawAmmoTpl( chosenCaliber, staticAmmoDist, defaultCartridgeTpl, weapon?._props?.Chambers[0]?._props?.filters[0]?.Filter, ); if (!cartridgeTpl) { return; } 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); if (!cartridgeDetails[0]) { this.logger.error(this.localisationService.getText("item-invalid_tpl_item", cartridgeTpl)); } const cartridgeMaxStackSize = cartridgeDetails[1]._props?.StackMaxSize; if (!cartridgeMaxStackSize) { this.logger.error(`Item with tpl: ${cartridgeTpl} lacks a _props or StackMaxSize property`); } // 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 * @param fallbackCartridgeTpl If a cartridge cannot be found in the above staticAmmoDist param, use this instead * @param cartridgeWhitelist OPTIONAL whitelist for cartridges * @returns Tpl of cartridge */ protected drawAmmoTpl( caliber: string, staticAmmoDist: Record, fallbackCartridgeTpl: string, cartridgeWhitelist: string[] = null, ): string { const ammos = staticAmmoDist[caliber]; if (!ammos && fallbackCartridgeTpl) { this.logger.error( `Unable to pick a cartridge for caliber: ${caliber} as staticAmmoDist has no data. using fallback value of ${fallbackCartridgeTpl}`, ); return fallbackCartridgeTpl; } if (!Array.isArray(ammos) && fallbackCartridgeTpl) { this.logger.error( `Unable to pick a cartridge for caliber: ${caliber}, the chosen staticAmmoDist data is not an array. Using fallback value of ${fallbackCartridgeTpl}`, ); return fallbackCartridgeTpl; } if (!ammos && !fallbackCartridgeTpl) { this.logger.error( `Unable to pick a cartridge for caliber: ${caliber} as staticAmmoDist has no data. No fallback value provided`, ); return; } const ammoArray = new ProbabilityObjectArray(this.mathUtil, this.cloner); for (const icd of ammos) { // Whitelist exists and tpl not inside it, skip // Fixes 9x18mm kedr issues if (cartridgeWhitelist && !cartridgeWhitelist.includes(icd.tpl)) { continue; } 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 = null, requiredOnly = false, ): Item[] { const result = itemToAdd; const incompatibleModTpls: Set = 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 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 randomly choose from * @param incompatibleModTpls Incompatible tpls to not allow * @returns Chosen tpl or null */ public getCompatibleTplFromArray(possibleTpls: string[], incompatibleModTpls: Set): 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", "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 * @returns New root id */ public remapRootItemId(itemWithChildren: Item[], newId = this.hashUtil.generate()): string { 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; } } return newId; } /** * Adopts orphaned items by resetting them as root "hideout" items. Helpful in situations where a parent has been * deleted from a group of items and there are children still referencing the missing parent. This method will * remove the reference from the children to the parent and set item properties to root values. * * @param rootId The ID of the "root" of the container. * @param items Array of Items that should be adjusted. * @returns Array of Items that have been adopted. */ public adoptOrphanedItems(rootId: string, items: Item[]): Item[] { for (const item of items) { // Check if the item's parent exists. const parentExists = items.some((parentItem) => parentItem._id === item.parentId); // If the parent does not exist and the item is not already a 'hideout' item, adopt the orphaned item by // setting the parent ID to the PMCs inventory equipment ID, the slot ID to 'hideout', and remove the location. if (!parentExists && item.parentId !== rootId && item.slotId !== "hideout") { item.parentId = rootId; item.slotId = "hideout"; delete item.location; } } return items; } /** * Populate a Map object of items for quick lookup using their ID. * * @param items An array of Items that should be added to a Map. * @returns A Map where the keys are the item IDs and the values are the corresponding Item objects. */ public generateItemsMap(items: Item[]): Map { const itemsMap = new Map(); for (const item of items) { itemsMap.set(item._id, item); } return itemsMap; } /** * Add a blank upd object to passed in item if it does not exist already * @param item item to add upd to * @param warningMessageWhenMissing text to write to log when upd object was not found * @returns True when upd object was added */ public addUpdObjectToItem(item: Item, warningMessageWhenMissing: string = null): boolean { if (!item.upd) { item.upd = {}; if (warningMessageWhenMissing) { this.logger.debug(warningMessageWhenMissing); } return true; } return false; } } // eslint-disable-next-line @typescript-eslint/no-namespace namespace ItemHelper { export interface ItemSize { width: number height: number } }