0
0
mirror of https://github.com/sp-tarkov/server.git synced 2025-02-13 09:50:43 -05:00
server/project/src/helpers/InventoryHelper.ts
Tyfon e58ddac0cc Fix InventoryHelper.canPlaceItemInContainer being backwards (!367)
For whatever reason, `InventoryHelper.canPlaceItemInContainer()` currently returns:
`true` if the item CANNOT be placed in the container
`undefined` if the item CAN be placed
`false` if the function thought it could but then failed when trying to (never happens?)

This didn't cause problems because the only two places that call it also treat the return value backwards - both of which are also fixed in this PR.

Reviewed-on: SPT/Server#367
Co-authored-by: Tyfon <tyfon7@outlook.com>
Co-committed-by: Tyfon <tyfon7@outlook.com>
2024-07-02 22:05:32 +00:00

1276 lines
46 KiB
TypeScript

import { inject, injectable } from "tsyringe";
import { ContainerHelper } from "@spt/helpers/ContainerHelper";
import { DialogueHelper } from "@spt/helpers/DialogueHelper";
import { ItemHelper } from "@spt/helpers/ItemHelper";
import { PaymentHelper } from "@spt/helpers/PaymentHelper";
import { PresetHelper } from "@spt/helpers/PresetHelper";
import { ProfileHelper } from "@spt/helpers/ProfileHelper";
import { TraderAssortHelper } from "@spt/helpers/TraderAssortHelper";
import { IPmcData } from "@spt/models/eft/common/IPmcData";
import { Inventory } from "@spt/models/eft/common/tables/IBotBase";
import { Item, Location, Upd } from "@spt/models/eft/common/tables/IItem";
import { IAddItemDirectRequest } from "@spt/models/eft/inventory/IAddItemDirectRequest";
import { IAddItemsDirectRequest } from "@spt/models/eft/inventory/IAddItemsDirectRequest";
import { IInventoryMergeRequestData } from "@spt/models/eft/inventory/IInventoryMergeRequestData";
import { IInventoryMoveRequestData } from "@spt/models/eft/inventory/IInventoryMoveRequestData";
import { IInventoryRemoveRequestData } from "@spt/models/eft/inventory/IInventoryRemoveRequestData";
import { IInventorySplitRequestData } from "@spt/models/eft/inventory/IInventorySplitRequestData";
import { IInventoryTransferRequestData } from "@spt/models/eft/inventory/IInventoryTransferRequestData";
import { IItemEventRouterResponse } from "@spt/models/eft/itemEvent/IItemEventRouterResponse";
import { BackendErrorCodes } from "@spt/models/enums/BackendErrorCodes";
import { BaseClasses } from "@spt/models/enums/BaseClasses";
import { BonusType } from "@spt/models/enums/BonusType";
import { ConfigTypes } from "@spt/models/enums/ConfigTypes";
import { IInventoryConfig, RewardDetails } from "@spt/models/spt/config/IInventoryConfig";
import { IOwnerInventoryItems } from "@spt/models/spt/inventory/IOwnerInventoryItems";
import { ILogger } from "@spt/models/spt/utils/ILogger";
import { ConfigServer } from "@spt/servers/ConfigServer";
import { DatabaseServer } from "@spt/servers/DatabaseServer";
import { FenceService } from "@spt/services/FenceService";
import { LocalisationService } from "@spt/services/LocalisationService";
import { ICloner } from "@spt/utils/cloners/ICloner";
import { HashUtil } from "@spt/utils/HashUtil";
import { HttpResponseUtil } from "@spt/utils/HttpResponseUtil";
@injectable()
export class InventoryHelper
{
protected inventoryConfig: IInventoryConfig;
constructor(
@inject("PrimaryLogger") protected logger: ILogger,
@inject("HashUtil") protected hashUtil: HashUtil,
@inject("HttpResponseUtil") protected httpResponse: HttpResponseUtil,
@inject("FenceService") protected fenceService: FenceService,
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
@inject("PaymentHelper") protected paymentHelper: PaymentHelper,
@inject("TraderAssortHelper") protected traderAssortHelper: TraderAssortHelper,
@inject("DialogueHelper") protected dialogueHelper: DialogueHelper,
@inject("ItemHelper") protected itemHelper: ItemHelper,
@inject("ContainerHelper") protected containerHelper: ContainerHelper,
@inject("ProfileHelper") protected profileHelper: ProfileHelper,
@inject("PresetHelper") protected presetHelper: PresetHelper,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("ConfigServer") protected configServer: ConfigServer,
@inject("PrimaryCloner") protected cloner: ICloner,
)
{
this.inventoryConfig = this.configServer.getConfig(ConfigTypes.INVENTORY);
}
/**
* Add multiple items to player stash (assuming they all fit)
* @param sessionId Session id
* @param request IAddItemsDirectRequest request
* @param pmcData Player profile
* @param output Client response object
*/
public addItemsToStash(
sessionId: string,
request: IAddItemsDirectRequest,
pmcData: IPmcData,
output: IItemEventRouterResponse,
): void
{
// Check all items fit into inventory before adding
if (!this.canPlaceItemsInInventory(sessionId, request.itemsWithModsToAdd))
{
// No space, exit
this.httpResponse.appendErrorToOutput(
output,
this.localisationService.getText("inventory-no_stash_space"),
BackendErrorCodes.NOTENOUGHSPACE,
);
return;
}
for (const itemToAdd of request.itemsWithModsToAdd)
{
const addItemRequest: IAddItemDirectRequest = {
itemWithModsToAdd: itemToAdd,
foundInRaid: request.foundInRaid,
useSortingTable: request.useSortingTable,
callback: request.callback,
};
// Add to player inventory
this.addItemToStash(sessionId, addItemRequest, pmcData, output);
if (output.warnings.length > 0)
{
return;
}
}
}
/**
* Add whatever is passed in `request.itemWithModsToAdd` into player inventory (if it fits)
* @param sessionId Session id
* @param request addItemDirect request
* @param pmcData Player profile
* @param output Client response object
*/
public addItemToStash(
sessionId: string,
request: IAddItemDirectRequest,
pmcData: IPmcData,
output: IItemEventRouterResponse,
): void
{
const itemWithModsToAddClone = this.cloner.clone(request.itemWithModsToAdd);
// Get stash layouts ready for use
const stashFS2D = this.getStashSlotMap(pmcData, sessionId);
const sortingTableFS2D = this.getSortingTableSlotMap(pmcData);
// Find empty slot in stash for item being added - adds 'location' + parentid + slotId properties to root item
this.placeItemInInventory(
stashFS2D,
sortingTableFS2D,
itemWithModsToAddClone,
pmcData.Inventory,
request.useSortingTable,
output,
);
if (output.warnings.length > 0)
{
// Failed to place, error out
return;
}
// Apply/remove FiR to item + mods
this.setFindInRaidStatusForItem(itemWithModsToAddClone, request.foundInRaid);
// Remove trader properties from root item
this.removeTraderRagfairRelatedUpdProperties(itemWithModsToAddClone[0].upd);
// Run callback
try
{
if (typeof request.callback === "function")
{
request.callback(itemWithModsToAddClone[0].upd.StackObjectsCount);
}
}
catch (err)
{
// Callback failed
const message
= typeof err?.message === "string" ? err.message : this.localisationService.getText("http-unknown_error");
this.httpResponse.appendErrorToOutput(output, message);
return;
}
// Add item + mods to output and profile inventory
output.profileChanges[sessionId].items.new.push(...itemWithModsToAddClone);
pmcData.Inventory.items.push(...itemWithModsToAddClone);
this.logger.debug(
`Added ${itemWithModsToAddClone[0].upd?.StackObjectsCount ?? 1} item: ${
itemWithModsToAddClone[0]._tpl
} with: ${itemWithModsToAddClone.length - 1} mods to inventory`,
);
}
/**
* Set FiR status for an item + its children
* @param itemWithChildren An item
* @param foundInRaid Item was found in raid
*/
protected setFindInRaidStatusForItem(itemWithChildren: Item[], foundInRaid: boolean): void
{
for (const item of itemWithChildren)
{
// Ensure item has upd object
this.itemHelper.addUpdObjectToItem(item);
if (foundInRaid)
{
item.upd.SpawnedInSession = foundInRaid;
}
else
{
delete item.upd.SpawnedInSession;
}
}
}
/**
* Remove properties from a Upd object used by a trader/ragfair that are unnecessary to a player
* @param upd Object to update
*/
protected removeTraderRagfairRelatedUpdProperties(upd: Upd): void
{
if (upd.UnlimitedCount !== undefined)
{
delete upd.UnlimitedCount;
}
if (upd.BuyRestrictionCurrent !== undefined)
{
delete upd.BuyRestrictionCurrent;
}
if (upd.BuyRestrictionMax !== undefined)
{
delete upd.BuyRestrictionMax;
}
}
/**
* Can all probided items be added into player inventory
* @param sessionId Player id
* @param itemsWithChildren array of items with children to try and fit
* @returns True all items fit
*/
public canPlaceItemsInInventory(sessionId: string, itemsWithChildren: Item[][]): boolean
{
const pmcData = this.profileHelper.getPmcProfile(sessionId);
const stashFS2D = this.cloner.clone(this.getStashSlotMap(pmcData, sessionId));
for (const itemWithChildren of itemsWithChildren)
{
if (!this.canPlaceItemInContainer(stashFS2D, itemWithChildren))
{
return false;
}
}
return true;
}
/**
* Do the provided items all fit into the grid
* @param containerFS2D Container grid to fit items into
* @param itemsWithChildren items to try and fit into grid
* @returns True all fit
*/
public canPlaceItemsInContainer(containerFS2D: number[][], itemsWithChildren: Item[][]): boolean
{
for (const itemWithChildren of itemsWithChildren)
{
if (!this.canPlaceItemInContainer(containerFS2D, itemWithChildren))
{
return false;
}
}
return true;
}
/**
* Does an item fit into a container grid
* @param containerFS2D Container grid
* @param itemWithChildren item to check fits
* @returns True it fits
*/
public canPlaceItemInContainer(containerFS2D: number[][], itemWithChildren: Item[]): boolean
{
// Get x/y size of item
const rootItem = itemWithChildren[0];
const itemSize = this.getItemSize(rootItem._tpl, rootItem._id, itemWithChildren);
// Look for a place to slot item into
const findSlotResult = this.containerHelper.findSlotForItem(containerFS2D, itemSize[0], itemSize[1]);
if (findSlotResult.success)
{
try
{
this.containerHelper.fillContainerMapWithItem(
containerFS2D,
findSlotResult.x,
findSlotResult.y,
itemSize[0],
itemSize[1],
findSlotResult.rotation,
);
}
catch (err)
{
const errorText = typeof err === "string" ? ` -> ${err}` : err.message;
this.logger.error(this.localisationService.getText("inventory-unable_to_fit_item_into_inventory", errorText));
return false;
}
// Success! exit
return true;
}
return false;
}
/**
* Find a free location inside a container to fit the item
* @param containerFS2D Container grid to add item to
* @param itemWithChildren Item to add to grid
* @param containerId Id of the container we're fitting item into
* @param desiredSlotId slot id value to use, default is "hideout"
*/
public placeItemInContainer(
containerFS2D: number[][],
itemWithChildren: Item[],
containerId: string,
desiredSlotId = "hideout",
): void
{
// Get x/y size of item
const rootItemAdded = itemWithChildren[0];
const itemSize = this.getItemSize(rootItemAdded._tpl, rootItemAdded._id, itemWithChildren);
// Look for a place to slot item into
const findSlotResult = this.containerHelper.findSlotForItem(containerFS2D, itemSize[0], itemSize[1]);
if (findSlotResult.success)
{
try
{
this.containerHelper.fillContainerMapWithItem(
containerFS2D,
findSlotResult.x,
findSlotResult.y,
itemSize[0],
itemSize[1],
findSlotResult.rotation,
);
}
catch (err)
{
const errorText = typeof err === "string" ? ` -> ${err}` : err.message;
this.logger.error(this.localisationService.getText("inventory-fill_container_failed", errorText));
return;
}
// Store details for object, incuding container item will be placed in
rootItemAdded.parentId = containerId;
rootItemAdded.slotId = desiredSlotId;
rootItemAdded.location = {
x: findSlotResult.x,
y: findSlotResult.y,
r: findSlotResult.rotation ? 1 : 0,
rotation: findSlotResult.rotation,
};
// Success! exit
return;
}
}
/**
* Find a location to place an item into inventory and place it
* @param stashFS2D 2-dimensional representation of the container slots
* @param sortingTableFS2D 2-dimensional representation of the sorting table slots
* @param itemWithChildren Item to place with children
* @param playerInventory Players inventory
* @param useSortingTable Should sorting table to be used if main stash has no space
* @param output output to send back to client
*/
protected placeItemInInventory(
stashFS2D: number[][],
sortingTableFS2D: number[][],
itemWithChildren: Item[],
playerInventory: Inventory,
useSortingTable: boolean,
output: IItemEventRouterResponse,
): void
{
// Get x/y size of item
const rootItem = itemWithChildren[0];
const itemSize = this.getItemSize(rootItem._tpl, rootItem._id, itemWithChildren);
// Look for a place to slot item into
const findSlotResult = this.containerHelper.findSlotForItem(stashFS2D, itemSize[0], itemSize[1]);
if (findSlotResult.success)
{
try
{
this.containerHelper.fillContainerMapWithItem(
stashFS2D,
findSlotResult.x,
findSlotResult.y,
itemSize[0],
itemSize[1],
findSlotResult.rotation,
);
}
catch (err)
{
handleContainerPlacementError(err, output);
return;
}
// Store details for object, incuding container item will be placed in
rootItem.parentId = playerInventory.stash;
rootItem.slotId = "hideout";
rootItem.location = {
x: findSlotResult.x,
y: findSlotResult.y,
r: findSlotResult.rotation ? 1 : 0,
rotation: findSlotResult.rotation,
};
// Success! exit
return;
}
// Space not found in main stash, use sorting table
if (useSortingTable)
{
const findSortingSlotResult = this.containerHelper.findSlotForItem(
sortingTableFS2D,
itemSize[0],
itemSize[1],
);
try
{
this.containerHelper.fillContainerMapWithItem(
sortingTableFS2D,
findSortingSlotResult.x,
findSortingSlotResult.y,
itemSize[0],
itemSize[1],
findSortingSlotResult.rotation,
);
}
catch (err)
{
handleContainerPlacementError(err, output);
return;
}
// Store details for object, incuding container item will be placed in
itemWithChildren[0].parentId = playerInventory.sortingTable;
itemWithChildren[0].location = {
x: findSortingSlotResult.x,
y: findSortingSlotResult.y,
r: findSortingSlotResult.rotation ? 1 : 0,
rotation: findSortingSlotResult.rotation,
};
}
else
{
this.httpResponse.appendErrorToOutput(
output,
this.localisationService.getText("inventory-no_stash_space"),
BackendErrorCodes.NOTENOUGHSPACE,
);
return;
}
function handleContainerPlacementError(err: any, output: IItemEventRouterResponse): void
{
const errorText = typeof err === "string" ? ` -> ${err}` : err.message;
this.logger.error(this.localisationService.getText("inventory-fill_container_failed", errorText));
this.httpResponse.appendErrorToOutput(
output,
this.localisationService.getText("inventory-no_stash_space"),
);
}
}
/**
* Handle Remove event
* Remove item from player inventory + insured items array
* Also deletes child items
* @param profile Profile to remove item from (pmc or scav)
* @param itemId Items id to remove
* @param sessionID Session id
* @param output OPTIONAL - IItemEventRouterResponse
*/
public removeItem(
profile: IPmcData,
itemId: string,
sessionID: string,
output?: IItemEventRouterResponse,
): void
{
if (!itemId)
{
this.logger.warning(this.localisationService.getText("inventory-unable_to_remove_item_no_id_given"));
return;
}
// Get children of item, they get deleted too
const itemToRemoveWithChildren = this.itemHelper.findAndReturnChildrenByItems(profile.Inventory.items, itemId);
const inventoryItems = profile.Inventory.items;
const insuredItems = profile.InsuredItems;
// We have output object, inform client of item deletion
if (output)
{
output.profileChanges[sessionID].items.del.push({ _id: itemId });
}
for (const childId of itemToRemoveWithChildren)
{
// We expect that each inventory item and each insured item has unique "_id", respective "itemId".
// Therefore we want to use a NON-Greedy function and escape the iteration as soon as we find requested item.
const inventoryIndex = inventoryItems.findIndex((item) => item._id === childId);
if (inventoryIndex !== -1)
{
inventoryItems.splice(inventoryIndex, 1);
}
else
{
this.logger.warning(this.localisationService.getText("inventory-unable_to_remove_item_id_not_found",
{
childId: childId,
profileId: profile._id,
}));
}
const insuredIndex = insuredItems.findIndex((item) => item.itemId === childId);
if (insuredIndex !== -1)
{
insuredItems.splice(insuredIndex, 1);
}
}
}
/**
* Delete desired item from a player profiles mail
* @param sessionId Session id
* @param removeRequest Remove request
* @param output OPTIONAL - IItemEventRouterResponse
*/
public removeItemAndChildrenFromMailRewards(
sessionId: string,
removeRequest: IInventoryRemoveRequestData,
output?: IItemEventRouterResponse,
): void
{
const fullProfile = this.profileHelper.getFullProfile(sessionId);
// Iterate over all dialogs and look for mesasage with key from request, that has item (and maybe its children) we want to remove
const dialogs = Object.values(fullProfile.dialogues);
for (const dialog of dialogs)
{
const messageWithReward = dialog.messages.find((x) => x._id === removeRequest.fromOwner.id);
if (messageWithReward)
{
// Find item + any possible children and remove them from mails items array
const itemWithChildern = this.itemHelper.findAndReturnChildrenAsItems(
messageWithReward.items.data,
removeRequest.item,
);
for (const itemToDelete of itemWithChildern)
{
// Get index of item to remove from reward array + remove it
const indexOfItemToRemove = messageWithReward.items.data.indexOf(itemToDelete);
if (indexOfItemToRemove === -1)
{
this.logger.error(
`Unable to remove item: ${removeRequest.item} from mail: ${removeRequest.fromOwner.id} as item could not be found, restart client immediately to prevent data corruption`,
);
continue;
}
messageWithReward.items.data.splice(indexOfItemToRemove, 1);
}
// Flag message as having no rewards if all removed
const hasRewardItemsRemaining = messageWithReward?.items.data?.length > 0;
messageWithReward.hasRewards = hasRewardItemsRemaining;
messageWithReward.rewardCollected = !hasRewardItemsRemaining;
}
}
}
/**
* Find item by id in player inventory and remove x of its count
* @param pmcData player profile
* @param itemId Item id to decrement StackObjectsCount of
* @param countToRemove Number of item to remove
* @param sessionID Session id
* @param output IItemEventRouterResponse
* @returns IItemEventRouterResponse
*/
public removeItemByCount(
pmcData: IPmcData,
itemId: string,
countToRemove: number,
sessionID: string,
output?: IItemEventRouterResponse,
): IItemEventRouterResponse
{
if (!itemId)
{
return output;
}
// Goal is to keep removing items until we can remove part of an items stack
const itemsToReduce = this.itemHelper.findAndReturnChildrenAsItems(pmcData.Inventory.items, itemId);
let remainingCount = countToRemove;
for (const itemToReduce of itemsToReduce)
{
const itemStackSize = this.itemHelper.getItemStackSize(itemToReduce);
// Remove whole stack
if (remainingCount >= itemStackSize)
{
remainingCount -= itemStackSize;
this.removeItem(pmcData, itemToReduce._id, sessionID, output);
}
else
{
itemToReduce.upd.StackObjectsCount -= remainingCount;
remainingCount = 0;
if (output)
{
output.profileChanges[sessionID].items.change.push(itemToReduce);
}
}
if (remainingCount === 0)
{
// Desired count of item has been removed / we ran out of items to remove
break;
}
}
return output;
}
/**
* Get the height and width of an item - can have children that alter size
* @param itemTpl Item to get size of
* @param itemID Items id to get size of
* @param inventoryItems
* @returns [width, height]
*/
public getItemSize(itemTpl: string, itemID: string, inventoryItems: Item[]): number[]
{
// -> Prepares item Width and height returns [sizeX, sizeY]
return this.getSizeByInventoryItemHash(itemTpl, itemID, this.getInventoryItemHash(inventoryItems));
}
/**
* Calculates the size of an item including attachements
* takes into account if item is folded
* @param itemTpl Items template id
* @param itemID Items id
* @param inventoryItemHash Hashmap of inventory items
* @returns An array representing the [width, height] of the item
*/
protected getSizeByInventoryItemHash(
itemTpl: string,
itemID: string,
inventoryItemHash: InventoryHelper.InventoryItemHash,
): number[]
{
const toDo = [itemID];
const result = this.itemHelper.getItem(itemTpl);
const tmpItem = result[1];
// Invalid item or no object
if (!(result[0] && result[1]))
{
this.logger.error(this.localisationService.getText("inventory-invalid_item_missing_from_db", itemTpl));
}
// Item found but no _props property
if (tmpItem && !tmpItem._props)
{
this.localisationService.getText("inventory-item_missing_props_property", {
itemTpl: itemTpl,
itemName: tmpItem?._name,
});
}
// No item object or getItem() returned false
if (!(tmpItem && result[0]))
{
// return default size of 1x1
this.logger.error(this.localisationService.getText("inventory-return_default_size", itemTpl));
return [1, 1]; // Invalid input data, return defaults
}
const rootItem = inventoryItemHash.byItemId[itemID];
const foldableWeapon = tmpItem._props.Foldable;
const foldedSlot = tmpItem._props.FoldedSlot;
let sizeUp = 0;
let sizeDown = 0;
let sizeLeft = 0;
let sizeRight = 0;
let forcedUp = 0;
let forcedDown = 0;
let forcedLeft = 0;
let forcedRight = 0;
let outX = tmpItem._props.Width;
const outY = tmpItem._props.Height;
// Item types to ignore
const skipThisItems: string[] = [
BaseClasses.BACKPACK,
BaseClasses.SEARCHABLE_ITEM,
BaseClasses.SIMPLE_CONTAINER,
];
const rootFolded = rootItem.upd?.Foldable && rootItem.upd.Foldable.Folded === true;
// The item itself is collapsible
if (foldableWeapon && (foldedSlot === undefined || foldedSlot === "") && rootFolded)
{
outX -= tmpItem._props.SizeReduceRight;
}
// Calculate size contribution from child items/attachments
if (!skipThisItems.includes(tmpItem._parent))
{
while (toDo.length > 0)
{
if (toDo[0] in inventoryItemHash.byParentId)
{
for (const item of inventoryItemHash.byParentId[toDo[0]])
{
// Filtering child items outside of mod slots, such as those inside containers, without counting their ExtraSize attribute
if (item.slotId.indexOf("mod_") < 0)
{
continue;
}
toDo.push(item._id);
// If the barrel is folded the space in the barrel is not counted
const itemResult = this.itemHelper.getItem(item._tpl);
if (!itemResult[0])
{
this.logger.error(
this.localisationService.getText(
"inventory-get_item_size_item_not_found_by_tpl",
item._tpl,
),
);
}
const itm = itemResult[1];
const childFoldable = itm._props.Foldable;
const childFolded = item.upd?.Foldable && item.upd.Foldable.Folded === true;
if (foldableWeapon && foldedSlot === item.slotId && (rootFolded || childFolded))
{
continue;
}
if (childFoldable && rootFolded && childFolded)
{
continue;
}
// Calculating child ExtraSize
if (itm._props.ExtraSizeForceAdd === true)
{
forcedUp += itm._props.ExtraSizeUp;
forcedDown += itm._props.ExtraSizeDown;
forcedLeft += itm._props.ExtraSizeLeft;
forcedRight += itm._props.ExtraSizeRight;
}
else
{
sizeUp = sizeUp < itm._props.ExtraSizeUp ? itm._props.ExtraSizeUp : sizeUp;
sizeDown = sizeDown < itm._props.ExtraSizeDown ? itm._props.ExtraSizeDown : sizeDown;
sizeLeft = sizeLeft < itm._props.ExtraSizeLeft ? itm._props.ExtraSizeLeft : sizeLeft;
sizeRight = sizeRight < itm._props.ExtraSizeRight ? itm._props.ExtraSizeRight : sizeRight;
}
}
}
toDo.splice(0, 1);
}
}
return [
outX + sizeLeft + sizeRight + forcedLeft + forcedRight,
outY + sizeUp + sizeDown + forcedUp + forcedDown,
];
}
/**
* Get a blank two-dimentional representation of a container
* @param containerH Horizontal size of container
* @param containerY Vertical size of container
* @returns Two-dimensional representation of container
*/
protected getBlankContainerMap(containerH: number, containerY: number): number[][]
{
return Array(containerY)
.fill(0)
.map(() => Array(containerH).fill(0));
}
/**
* @param containerH Horizontal size of container
* @param containerV Vertical size of container
* @param itemList
* @param containerId Id of the container
* @returns Two-dimensional representation of container
*/
public getContainerMap(containerH: number, containerV: number, itemList: Item[], containerId: string): number[][]
{
const container2D: number[][] = this.getBlankContainerMap(containerH, containerV);
const inventoryItemHash = this.getInventoryItemHash(itemList);
const containerItemHash = inventoryItemHash.byParentId[containerId];
if (!containerItemHash)
{
// No items in the container
return container2D;
}
for (const item of containerItemHash)
{
if (!("location" in item))
{
continue;
}
const tmpSize = this.getSizeByInventoryItemHash(item._tpl, item._id, inventoryItemHash);
const iW = tmpSize[0]; // x
const iH = tmpSize[1]; // y
const fH
= (item.location as Location).r === 1
|| (item.location as Location).r === "Vertical"
|| (item.location as Location).rotation === "Vertical"
? iW
: iH;
const fW
= (item.location as Location).r === 1
|| (item.location as Location).r === "Vertical"
|| (item.location as Location).rotation === "Vertical"
? iH
: iW;
const fillTo = (item.location as Location).x + fW;
for (let y = 0; y < fH; y++)
{
try
{
container2D[(item.location as Location).y + y].fill(1, (item.location as Location).x, fillTo);
}
catch (e)
{
this.logger.error(
this.localisationService.getText("inventory-unable_to_fill_container", {
id: item._id,
error: e,
}),
);
}
}
}
return container2D;
}
protected getInventoryItemHash(inventoryItem: Item[]): InventoryHelper.InventoryItemHash
{
const inventoryItemHash: InventoryHelper.InventoryItemHash = { byItemId: {}, byParentId: {} };
for (const item of inventoryItem)
{
inventoryItemHash.byItemId[item._id] = item;
if (!("parentId" in item))
{
continue;
}
if (!(item.parentId in inventoryItemHash.byParentId))
{
inventoryItemHash.byParentId[item.parentId] = [];
}
inventoryItemHash.byParentId[item.parentId].push(item);
}
return inventoryItemHash;
}
/**
* Return the inventory that needs to be modified (scav/pmc etc)
* Changes made to result apply to character inventory
* Based on the item action, determine whose inventories we should be looking at for from and to.
* @param request Item interaction request
* @param sessionId Session id / playerid
* @returns OwnerInventoryItems with inventory of player/scav to adjust
*/
public getOwnerInventoryItems(
request:
| IInventoryMoveRequestData
| IInventorySplitRequestData
| IInventoryMergeRequestData
| IInventoryTransferRequestData,
sessionId: string,
): IOwnerInventoryItems
{
let isSameInventory = false;
const pmcItems = this.profileHelper.getPmcProfile(sessionId).Inventory.items;
const scavData = this.profileHelper.getScavProfile(sessionId);
let fromInventoryItems = pmcItems;
let fromType = "pmc";
if (request.fromOwner)
{
if (request.fromOwner.id === scavData._id)
{
fromInventoryItems = scavData.Inventory.items;
fromType = "scav";
}
else if (request.fromOwner.type.toLocaleLowerCase() === "mail")
{
// Split requests dont use 'use' but 'splitItem' property
const item = "splitItem" in request ? request.splitItem : request.item;
fromInventoryItems = this.dialogueHelper.getMessageItemContents(request.fromOwner.id, sessionId, item);
fromType = "mail";
}
}
// Don't need to worry about mail for destination because client doesn't allow
// users to move items back into the mail stash.
let toInventoryItems = pmcItems;
let toType = "pmc";
// Destination is scav inventory, update values
if (request.toOwner?.id === scavData._id)
{
toInventoryItems = scavData.Inventory.items;
toType = "scav";
}
// From and To types match, same inventory
if (fromType === toType)
{
isSameInventory = true;
}
return {
from: fromInventoryItems,
to: toInventoryItems,
sameInventory: isSameInventory,
isMail: fromType === "mail",
};
}
/**
* Get a two dimensional array to represent stash slots
* 0 value = free, 1 = taken
* @param pmcData Player profile
* @param sessionID session id
* @returns 2-dimensional array
*/
protected getStashSlotMap(pmcData: IPmcData, sessionID: string): number[][]
{
const playerStashSize = this.getPlayerStashSize(sessionID);
return this.getContainerMap(
playerStashSize[0],
playerStashSize[1],
pmcData.Inventory.items,
pmcData.Inventory.stash,
);
}
/**
* Get a blank two-dimensional array representation of a container
* @param containerTpl Container to get data for
* @returns blank two-dimensional array
*/
public getContainerSlotMap(containerTpl: string): number[][]
{
const containerTemplate = this.itemHelper.getItem(containerTpl)[1];
const containerH = containerTemplate._props.Grids[0]._props.cellsH;
const containerV = containerTemplate._props.Grids[0]._props.cellsV;
return this.getBlankContainerMap(containerH, containerV);
}
/**
* Get a two-dimensional array representation of the players sorting table
* @param pmcData Player profile
* @returns two-dimensional array
*/
protected getSortingTableSlotMap(pmcData: IPmcData): number[][]
{
return this.getContainerMap(10, 45, pmcData.Inventory.items, pmcData.Inventory.sortingTable);
}
/**
* Get Players Stash Size
* @param sessionID Players id
* @returns Array of 2 values, horizontal and vertical stash size
*/
protected getPlayerStashSize(sessionID: string): Record<number, number>
{
const profile = this.profileHelper.getPmcProfile(sessionID);
const stashRowBonus = profile.Bonuses.find((bonus) => bonus.type === BonusType.STASH_ROWS);
// this sets automatically a stash size from items.json (its not added anywhere yet cause we still use base stash)
const stashTPL = this.getStashType(sessionID);
if (!stashTPL)
{
this.logger.error(this.localisationService.getText("inventory-missing_stash_size"));
}
const stashItemResult = this.itemHelper.getItem(stashTPL);
if (!stashItemResult[0])
{
this.logger.error(this.localisationService.getText("inventory-stash_not_found", stashTPL));
return;
}
const stashItemDetails = stashItemResult[1];
const firstStashItemGrid = stashItemDetails._props.Grids[0];
const stashH = firstStashItemGrid._props.cellsH !== 0 ? firstStashItemGrid._props.cellsH : 10;
let stashV = firstStashItemGrid._props.cellsV !== 0 ? firstStashItemGrid._props.cellsV : 66;
// Player has a bonus, apply to vertical size
if (stashRowBonus)
{
stashV += stashRowBonus.value;
}
return [stashH, stashV];
}
/**
* Get the players stash items tpl
* @param sessionID Player id
* @returns Stash tpl
*/
protected getStashType(sessionID: string): string
{
const pmcData = this.profileHelper.getPmcProfile(sessionID);
const stashObj = pmcData.Inventory.items.find((item) => item._id === pmcData.Inventory.stash);
if (!stashObj)
{
this.logger.error(this.localisationService.getText("inventory-unable_to_find_stash"));
}
return stashObj?._tpl;
}
/**
* Internal helper function to transfer an item + children from one profile to another.
* @param sourceItems Inventory of the source (can be non-player)
* @param toItems Inventory of the destination
* @param request Move request
*/
public moveItemToProfile(sourceItems: Item[], toItems: Item[], request: IInventoryMoveRequestData): void
{
this.handleCartridges(sourceItems, request);
// Get all children item has, they need to move with item
const idsToMove = this.itemHelper.findAndReturnChildrenByItems(sourceItems, request.item);
for (const itemId of idsToMove)
{
const itemToMove = sourceItems.find((item) => item._id === itemId);
if (!itemToMove)
{
this.logger.error(this.localisationService.getText("inventory-unable_to_find_item_to_move", itemId));
}
// Only adjust the values for parent item, not children (their values are already correctly tied to parent)
if (itemId === request.item)
{
itemToMove.parentId = request.to.id;
itemToMove.slotId = request.to.container;
if (request.to.location)
{
// Update location object
itemToMove.location = request.to.location;
}
else
{
// No location in request, delete it
if (itemToMove.location)
{
delete itemToMove.location;
}
}
}
toItems.push(itemToMove);
sourceItems.splice(sourceItems.indexOf(itemToMove), 1);
}
}
/**
* Internal helper function to move item within the same profile_f.
* @param pmcData profile to edit
* @param inventoryItems
* @param moveRequest client move request
* @returns True if move was successful
*/
public moveItemInternal(
pmcData: IPmcData,
inventoryItems: Item[],
moveRequest: IInventoryMoveRequestData,
): { success: boolean, errorMessage?: string }
{
this.handleCartridges(inventoryItems, moveRequest);
// Find item we want to 'move'
const matchingInventoryItem = inventoryItems.find((item) => item._id === moveRequest.item);
if (!matchingInventoryItem)
{
const errorMesage = `Unable to move item: ${moveRequest.item}, cannot find in inventory`;
this.logger.error(errorMesage);
return { success: false, errorMessage: errorMesage };
}
this.logger.debug(
`${moveRequest.Action} item: ${moveRequest.item} from slotid: ${matchingInventoryItem.slotId} to container: ${moveRequest.to.container}`,
);
// Don't move shells from camora to cartridges (happens when loading shells into mts-255 revolver shotgun)
if (matchingInventoryItem.slotId?.includes("camora_") && moveRequest.to.container === "cartridges")
{
this.logger.warning(
this.localisationService.getText("inventory-invalid_move_to_container", {
slotId: matchingInventoryItem.slotId,
container: moveRequest.to.container,
}),
);
return { success: true };
}
// Edit items details to match its new location
matchingInventoryItem.parentId = moveRequest.to.id;
matchingInventoryItem.slotId = moveRequest.to.container;
// Ensure fastpanel dict updates when item was moved out of fast-panel-accessible slot
this.updateFastPanelBinding(pmcData, matchingInventoryItem);
// Item has location propery, ensure its value is handled
if ("location" in moveRequest.to)
{
matchingInventoryItem.location = moveRequest.to.location;
}
else
{
// Moved from slot with location to one without, clean up
if (matchingInventoryItem.location)
{
delete matchingInventoryItem.location;
}
}
return { success: true };
}
/**
* Update fast panel bindings when an item is moved into a container that doesnt allow quick slot access
* @param pmcData Player profile
* @param itemBeingMoved item being moved
*/
protected updateFastPanelBinding(pmcData: IPmcData, itemBeingMoved: Item): void
{
// Find matching _id in fast panel
const fastPanelSlot = Object.entries(pmcData.Inventory.fastPanel)
.find(([itemId]) => itemId === itemBeingMoved._id);
if (!fastPanelSlot)
{
return;
}
// Get moved items parent (should be container item was put into)
const itemParent = pmcData.Inventory.items.find((item) => item._id === itemBeingMoved.parentId);
if (!itemParent)
{
return;
}
// Reset fast panel value if item was moved to a container other than pocket/rig (cant be used from fastpanel)
const wasMovedToFastPanelAccessibleContainer = ["pockets", "tacticalvest"].includes(itemParent?.slotId?.toLowerCase() ?? "");
if (!wasMovedToFastPanelAccessibleContainer)
{
pmcData.Inventory.fastPanel[fastPanelSlot[0]] = "";
}
}
/**
* Internal helper function to handle cartridges in inventory if any of them exist.
*/
protected handleCartridges(items: Item[], request: IInventoryMoveRequestData): void
{
// Not moving item into a cartridge slot, skip
if (request.to.container !== "cartridges")
{
return;
}
// Get a count of cartridges in existing magazine
const cartridgeCount = items.filter((item) => item.parentId === request.to.id).length;
request.to.location = cartridgeCount;
}
/**
* Get details for how a random loot container should be handled, max rewards, possible reward tpls
* @param itemTpl Container being opened
* @returns Reward details
*/
public getRandomLootContainerRewardDetails(itemTpl: string): RewardDetails
{
return this.inventoryConfig.randomLootContainers[itemTpl];
}
public getInventoryConfig(): IInventoryConfig
{
return this.inventoryConfig;
}
/**
* Recursively checks if the given item is
* inside the stash, that is it has the stash as
* ancestor with slotId=hideout
* @param pmcData Player profile
* @param itemToCheck Item to look for
* @returns True if item exists inside stash
*/
public isItemInStash(pmcData: IPmcData, itemToCheck: Item): boolean
{
// Create recursive helper function
const isParentInStash = (itemId: string): boolean =>
{
// Item not found / has no parent
const item = pmcData.Inventory.items.find((item) => item._id === itemId);
if (!item || !item.parentId)
{
return false;
}
// Root level. Items parent is the stash with slotId "hideout"
if (item.parentId === pmcData.Inventory.stash && item.slotId === "hideout")
{
return true;
}
// Recursive case: Check the items parent
return isParentInStash(item.parentId);
};
// Start recursive check
return isParentInStash(itemToCheck._id);
}
}
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace InventoryHelper
{
export interface InventoryItemHash
{
byItemId: Record<string, Item>
byParentId: Record<string, Item[]>
}
}