tiny-essentials
Version:
Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.
1,342 lines (1,198 loc) • 66.8 kB
JavaScript
'use strict';
/**
* Represents a registered item definition in the global registry.
*
* @typedef {Object} ItemDef
* @property {string} id - Unique identifier for the item.
* @property {number} weight - Weight of a single unit of the item.
* @property {InventoryMetadata} metadata - Default metadata for the item type.
* @property {number} maxStack - Maximum quantity per stack.
* @property {OnUseEvent|null} onUse - Callback triggered when the item is used.
* @property {string|null} type - Optional category/type identifier.
*/
/**
* Represents an individual stored item instance in the inventory.
*
* @typedef {Object} InventoryItem
* @property {string} id - Unique identifier for the item.
* @property {InventoryMetadata} metadata - Metadata specific to this item instance.
* @property {number} quantity - Number of units in this stack.
*/
/**
* A collection of item stacks stored in the inventory.
*
* @typedef {(InventoryItem|null)[]} InvSlots
*/
/**
* Metadata object used to store arbitrary key-value pairs for an item.
*
* @typedef {Record<string|number|symbol, any>} InventoryMetadata
*/
/**
* Represents a special slot (e.g., equipment) in the inventory.
*
* @typedef {Object} SpecialSlot
* @property {string|null} type - Optional slot category/type.
* @property {InventoryItem|null} item - Item currently equipped in this slot.
*/
/**
* Generic event callback triggered by inventory actions.
* @typedef {(payload: EventPayload) => void} OnEvent
*/
/**
* Event fired when an item is added to the inventory.
*
* @typedef {OnEvent} AddItemEvent
*/
/**
* Event fired when an item is setted to the inventory.
*
* @typedef {OnEvent} SetItemEvent
*/
/**
* Event fired when an item is removed from the inventory.
*
* @typedef {OnEvent} RemoveItemEvent
*/
/**
* Event fired when an item is used.
*
* @typedef {OnEvent} UseItemEvent
*/
/**
* A function executed when an inventory item is used.
* Can be assigned to handle custom "on use" behavior.
*
* @typedef {OnEvent} OnUseEvent
* @returns {void} - Does not return a value.
*/
/**
* Represents the supported event types for inventory actions.
* @typedef {'add'|'remove'|'use'|'set'} EventsType
*/
/**
* Callback used to filter items by matching metadata.
*
* @callback GetItemsByMetadataCallback
* @param {InventoryMetadata} metadata - The metadata object to match against.
* @param {InventoryItem} item - The current item being evaluated.
* @returns {boolean} - `true` if the item matches the metadata, otherwise `false`.
*/
/**
* Callback used when searching for an item in an inventory collection.
* Works similarly to `Array.prototype.find()` conditions.
*
* @callback FindItemCallback
* @param {InventoryItem} value - The current inventory item being evaluated.
* @param {number} index - The index of the current item in the array.
* @param {InventoryItem[]} items - The array of all inventory items.
* @returns {boolean} - `true` if the item matches the search criteria, otherwise `false`.
*/
/**
* Represents the fully serialized structure of a TinyInventory instance.
* Intended for pure JSON storage and transmission.
*
* @typedef {Object} SerializedInventory
* @property {'TinyInventory'} __schema - Schema identifier for validation.
* @property {number} version - Serialized format version.
* @property {number|null} maxWeight - Maximum allowed weight, or null if unlimited.
* @property {number|null} maxSlots - Maximum allowed slots, or null if unlimited.
* @property {number|null} maxSize - Maximum allowed size for items, or null if unlimited.
* @property {number} maxStack - Maximum stack size allowed per item.
* @property {(InventoryItem|null)[]} items - Flat inventory items.
* @property {Record<string, SpecialSlot>} specialSlots - Special equipment or reserved slots keyed by ID.
*/
/**
* Represents an entry from the inventory as a pair containing the item and its index in the collection.
* @typedef {[InventoryItem, number]} ItemListData
*/
/**
* Payload data dispatched when an inventory event occurs (e.g., add, remove, equip, unequip).
* @typedef {Object} EventPayload
* @property {number|null} index - Index of the item in its collection (null if not applicable).
* @property {InventoryItem|null} item - The item affected by the event.
* @property {boolean} isCollection - Whether the item came from a collection (true) or not.
* @property {string|null} specialSlot - ID of the special slot involved in the event, if any.
* @property {(forceSpace: boolean) => void} remove - Function to remove the item from its slot, optionally forcing space rules.
*/
/**
* Result of adding items to an inventory.
* @typedef {Object} AddItemResult
* @property {number} remaining - Quantity of the item that could NOT be added due to space/stack limits.
* @property {{ index: number; quantity: number }[]} placesAdded - Array of slot indexes in the inventory where the item was successfully added.
*/
/**
* TinyInventory — A flexible inventory management system.
*
* This class provides:
* - Standard inventory slots with configurable limits on weight, size, and stack.
* - Special slots for equipment, tools, or unique item types.
* - Full CRUD operations for items (add, remove, move, use, equip, unequip).
* - Metadata-aware operations to differentiate items with durability, enchantments, etc.
* - Serialization and deserialization to/from JSON for saving/loading inventory state.
* - Event triggers for 'add', 'remove', 'use', and 'set' actions.
*
* @beta
*/
class TinyInventory {
/**
* Registry of all item definitions available in TinyInventory.
* Keys are item IDs, values are configuration objects created with {@link TinyInventory.defineItem}.
* @type {Map<string, ItemDef>}
*/
static #ItemRegistry = new Map();
/**
* Returns a deep-cloned snapshot of all registered items.
* Ensures the caller cannot mutate the internal registry.
*
* @returns {Record<string, ItemDef>} A map of item IDs to their definitions.
*/
static get itemRegistry() {
/** @type {Record<string, ItemDef>} */
const results = {};
const items = Object.fromEntries(TinyInventory.#ItemRegistry);
for (const itemId in items)
results[itemId] = { ...items[itemId], metadata: { ...items[itemId].metadata } };
return results;
}
/**
* Defines or updates an item type in the global item registry.
* Stores key properties such as weight, stackability rules, and optional behavior callbacks.
*
* @param {Object} config - Item configuration object.
* @param {string} config.id - Unique identifier for the item.
* @param {number} [config.weight=0] - Weight of a single unit of the item.
* @param {InventoryMetadata} [config.metadata={}] - Default metadata for the item type.
* @param {number} [config.maxStack=1] - Maximum quantity allowed in a single stack.
* @param {OnUseEvent|null} [config.onUse=null] - Optional callback executed when the item is used.
* @param {string|null} [config.type=null] - Optional type/category identifier for the item.
* @throws {Error} If `id` is missing or not a string.
*/
static defineItem(config) {
if (!config || typeof config !== 'object')
throw new TypeError('Config must be a valid object.');
if (!config.id || typeof config.id !== 'string')
throw new TypeError("Item must have a valid string 'id'.");
if (config.weight !== undefined && (typeof config.weight !== 'number' || config.weight < 0))
throw new TypeError(`weight must be a number >= 0. Received: ${config.weight}`);
if (
config.maxStack !== undefined &&
(!Number.isInteger(config.maxStack) || config.maxStack <= 0)
)
throw new TypeError(`maxStack must be a positive integer. Received: ${config.maxStack}`);
if (config.metadata !== undefined && typeof config.metadata !== 'object')
throw new TypeError('metadata must be an object.');
if (config.onUse !== undefined && config.onUse !== null && typeof config.onUse !== 'function')
throw new TypeError('onUse must be a function or null.');
if (config.type !== undefined && config.type !== null && typeof config.type !== 'string')
throw new TypeError('type must be a string or null.');
TinyInventory.#ItemRegistry.set(config.id, {
id: config.id,
weight: config.weight || 0,
maxStack: config.maxStack || 1,
metadata: config.metadata || {},
type: config.type ?? null,
onUse: typeof config.onUse === 'function' ? config.onUse : null,
});
}
/**
* Removes an item definition from the global registry.
*
* @param {string} itemId - Unique identifier of the item to remove.
* @returns {boolean} True if the item was removed, false if it did not exist.
*/
static removeItem(itemId) {
if (typeof itemId !== 'string') throw new TypeError('itemId must be a string.');
return TinyInventory.#ItemRegistry.delete(itemId);
}
/**
* Checks whether an item is registered.
*
* @param {string} itemId - The item ID to check.
* @returns {boolean} True if the item exists in the registry, false otherwise.
*/
static hasItem(itemId) {
if (typeof itemId !== 'string') throw new TypeError('itemId must be a string.');
return TinyInventory.#ItemRegistry.has(itemId);
}
/**
* Retrieves an item definition from the registry.
*
* @param {string} itemId - The ID of the item to retrieve.
* @returns {ItemDef} The definition of the requested item.
* @throws {Error} If the item is not registered.
*/
static getItem(itemId) {
if (typeof itemId !== 'string') throw new TypeError('itemId must be a string.');
const def = TinyInventory.#ItemRegistry.get(itemId);
if (!def) throw new Error(`Item '${itemId}' not defined in registry.`);
return def;
}
/////////////////////////////////////////////////////////////////
/** @type {Map<string, SpecialSlot>} */
#specialSlots = new Map();
/**
* Event listeners
*/
#events = {
/** @type {AddItemEvent[]} */
add: [],
/** @type {RemoveItemEvent[]} */
remove: [],
/** @type {UseItemEvent[]} */
use: [],
/** @type {SetItemEvent[]} */
set: [],
};
/** @type {InvSlots} */
#items = [];
/** @type {number} */
#maxStack;
/** @type {number|null} */
#maxSize;
/** @type {number|null} */
#maxSlots;
/** @type {number|null} */
#maxWeight;
/////////////////////////////////////////////////////////////////
/**
* Gets the maximum stack size allowed per item.
* @returns {number}
*/
get maxStack() {
return this.#maxStack;
}
/**
* Sets the maximum stack size per item.
* @param {number} value - Must be a positive integer.
* @throws {Error} If the value is not a valid positive integer.
*/
set maxStack(value) {
if (!Number.isInteger(value) || (Number.isFinite(value) && value <= 0)) {
throw new TypeError(`maxStack must be a positive integer. Received: ${value}`);
}
this.#maxStack = value;
}
/**
* Gets the maximum item size allowed.
* @returns {number|null}
*/
get maxSize() {
return this.#maxSize;
}
/**
* Sets the maximum item size.
* @param {number|null} value - Must be a positive integer or null.
* @throws {Error} If the value is not null or a positive integer.
*/
set maxSize(value) {
if (value !== null && (!Number.isInteger(value) || value <= 0)) {
throw new TypeError(`maxSize must be null or a positive integer. Received: ${value}`);
}
this.#maxSize = value;
}
/**
* Gets the maximum number of slots allowed.
* @returns {number|null}
*/
get maxSlots() {
return this.#maxSlots;
}
/**
* Sets the maximum number of slots.
* @param {number|null} value - Must be a positive integer or null.
* @throws {Error} If the value is not null or a positive integer.
*/
set maxSlots(value) {
if (value !== null && (!Number.isInteger(value) || value <= 0)) {
throw new TypeError(`maxSlots must be null or a positive integer. Received: ${value}`);
}
this.#maxSlots = value;
}
/**
* Gets the maximum inventory weight allowed.
* @returns {number|null}
*/
get maxWeight() {
return this.#maxWeight;
}
/**
* Sets the maximum inventory weight.
* @param {number|null} value - Must be a positive number or null.
* @throws {Error} If the value is not null or a positive number.
*/
set maxWeight(value) {
if (value !== null && (typeof value !== 'number' || value <= 0)) {
throw new TypeError(`maxWeight must be null or a positive number. Received: ${value}`);
}
this.#maxWeight = value;
}
/////////////////////////////////////////////////////////////////
/**
* Gets the registered inventory event listeners.
* Always returns a clone to prevent external mutation.
* @returns {{ add: AddItemEvent[], remove: RemoveItemEvent[], use: UseItemEvent[], set: SetItemEvent[] }}
*/
get events() {
return {
add: [...this.#events.add],
remove: [...this.#events.remove],
use: [...this.#events.use],
set: [...this.#events.set],
};
}
/**
* Gets the current inventory item slots.
* Always returns a clone to prevent external mutation.
* @returns {InvSlots}
*/
get items() {
return [...this.#items].map((item) => (item ? this.#cloneItemData(item) : null));
}
/**
* Gets the current special slots.
* Always returns a clone to prevent external mutation.
* @returns {Map<string, SpecialSlot>}
*/
get specialSlots() {
return new Map(
[...this.#specialSlots.entries()].map(([slotId, slot]) => [
slotId,
{
type: slot.type,
item: slot.item ? this.#cloneItemData(slot.item) : null,
},
]),
);
}
/////////////////////////////////////////////////////////////////
/**
* Gets the total quantity of items in the inventory.
* Unlike slot count, this sums up the `quantity` of each item.
*
* @returns {number} - The total number of item units stored in the inventory.
*/
get size() {
const items = this.getAllItems();
let amount = 0;
for (const item of items) amount += item.quantity;
return amount;
}
/**
* Gets the total quantity of used slots in the inventory.
*
* @returns {number} - The total number of used slots stored in the inventory.
*/
get slotsSize() {
return this.getAllItems().length;
}
/**
* Gets the total weight of all items in the inventory.
* @returns {number} The total weight.
*/
get weight() {
return this.getAllItems().reduce((total, item) => {
const def = TinyInventory.getItem(item.id);
return total + (def?.weight || 0) * item.quantity;
}, 0);
}
/////////////////////////////////////////////////////////////////
/**
* Cleans up unnecessary trailing nulls in a collection.
* @private
*/
_cleanNulls() {
let lastIndex = this.#items.length - 1;
// Find last non-null index
while (lastIndex >= 0 && this.#items[lastIndex] === null) lastIndex--;
// Slice up to last non-null + 1
this.#items = this.#items.slice(0, lastIndex + 1);
}
/**
* Creates a new TinyInventory instance.
*
* @param {Object} [options={}] - Configuration options for the inventory.
* @param {number|null} [options.maxWeight=null] - Maximum allowed total weight (null for no limit).
* @param {number|null} [options.maxSlots=null] - Maximum number of item slots (null for no limit).
* @param {number|null} [options.maxSize=null] - Maximum number of total item amount (null for no limit).
* @param {number} [options.maxStack=Infinity] - Global maximum stack size (per slot).
* @param {Record<string, { type: string | null; }>} [options.specialSlots] - IDs for special slots (e.g., "helmet", "weapon").
*/
constructor(options = {}) {
if (typeof options !== 'object' || options === null)
throw new TypeError('`options` must be an object.');
if (
options.maxWeight !== undefined &&
options.maxWeight !== null &&
typeof options.maxWeight !== 'number'
)
throw new TypeError('`maxWeight` must be a number or null.');
if (
options.maxSlots !== undefined &&
options.maxSlots !== null &&
typeof options.maxSlots !== 'number'
)
throw new TypeError('`maxSlots` must be a number or null.');
if (
options.maxSize !== undefined &&
options.maxSize !== null &&
typeof options.maxSize !== 'number'
)
throw new TypeError('`maxSize` must be a number or null.');
if (options.maxStack !== undefined && typeof options.maxStack !== 'number')
throw new TypeError('`maxStack` must be a number.');
if (options.specialSlots !== undefined && typeof options.specialSlots !== 'object')
throw new TypeError('`specialSlots` must be an object if defined.');
this.#maxWeight = options.maxWeight ?? null;
this.#maxSlots = options.maxSlots ?? null;
this.#maxSize = options.maxSize ?? null;
this.#maxStack = options.maxStack ?? Infinity;
if (options.specialSlots) {
for (const name in options.specialSlots) {
const slot = options.specialSlots[name];
if (typeof slot !== 'object' || slot === null)
throw new TypeError('Each `specialSlot` entry must be an object.');
if (slot.type !== undefined && slot.type !== null && typeof slot.type !== 'string')
throw new TypeError('`specialSlot.type` must be a string or null.');
this.#specialSlots.set(name, { type: slot.type ?? null, item: null });
}
}
}
/////////////////////////////////////////////////////////////////
/**
* Checks if there is available space based on slot and weight limits.
* @param {Object} [settings={}] - Optional configuration for the space check.
* @param {number} [settings.weight=0] - Extra weight to include in the calculation (e.g., previewing an item addition).
* @param {number} [settings.sizeLength=0] - Extra item count to include in the calculation (e.g., previewing a amount usage).
* @param {number} [settings.slotsLength=0] - Extra item count to include in the calculation (e.g., previewing a slot usage).
* @returns {boolean} True if there is space; false otherwise.
*/
hasSpace({ weight = 0, sizeLength = 0, slotsLength = 0 } = {}) {
if (typeof weight !== 'number') throw new TypeError('`weight` must be a number.');
if (typeof sizeLength !== 'number') throw new TypeError('`sizeLength` must be a number.');
if (typeof slotsLength !== 'number') throw new TypeError('`slotsLength` must be a number.');
if (this.areFull(sizeLength) || this.areFullSlots(slotsLength) || this.isHeavy(weight))
return false;
return true;
}
/**
* Checks if the inventory weight exceeds the maximum allowed limit,
* optionally considering an additional weight.
*
* @param {number} [extraWeight=0] - Additional weight to consider in the calculation.
* @returns {boolean} - Returns `true` if the total weight (current + extra) exceeds `maxWeight`, otherwise `false`.
*/
isHeavy(extraWeight = 0) {
if (typeof extraWeight !== 'number') throw new TypeError('`extraWeight` must be a number.');
if (this.#maxWeight !== null && this.weight + extraWeight > this.#maxWeight) return true;
return false;
}
/**
* Checks if the inventory amount has reached its maximum capacity.
*
* @param {number} [extraLength=0] - Additional length to consider in the calculation.
* @returns {boolean} - Returns `true` if the total number of items is greater than or equal to `maxSlots`, otherwise `false`.
*/
areFull(extraLength = 0) {
if (typeof extraLength !== 'number') throw new TypeError('`extraLength` must be a number.');
if (this.#maxSize !== null && this.size + extraLength > this.#maxSize) return true;
return false;
}
/**
* Checks if the inventory has reached its maximum amount capacity.
*
* @param {number} [extraLength=0] - Additional length to consider in the calculation.
* @returns {boolean} - Returns `true` if the total number of items is greater than or equal to `maxSlots`, otherwise `false`.
*/
isFull(extraLength = 0) {
if (typeof extraLength !== 'number') throw new TypeError('`extraLength` must be a number.');
if (this.#maxSize !== null && this.size + extraLength >= this.#maxSize) return true;
return false;
}
/**
* Checks if the inventory slots has reached its maximum slot capacity.
*
* @param {number} [extraLength=0] - Additional length to consider in the calculation.
* @returns {boolean} - Returns `true` if the total number of items is greater than or equal to `maxSlots`, otherwise `false`.
*/
areFullSlots(extraLength = 0) {
if (typeof extraLength !== 'number') throw new TypeError('`extraLength` must be a number.');
if (this.#maxSlots !== null && this.slotsSize + extraLength > this.#maxSlots) return true;
return false;
}
/**
* Checks if the inventory has reached its maximum slot capacity.
*
* @param {number} [extraLength=0] - Additional length to consider in the calculation.
* @returns {boolean} - Returns `true` if the total number of items is greater than or equal to `maxSlots`, otherwise `false`.
*/
isFullSlots(extraLength = 0) {
if (typeof extraLength !== 'number') throw new TypeError('`extraLength` must be a number.');
if (this.#maxSlots !== null && this.slotsSize + extraLength >= this.#maxSlots) return true;
return false;
}
/////////////////////////////////////////////////////////////////
/**
* Internal event trigger.
* @param {EventsType} type - Event type.
* @param {EventPayload} payload - Event data passed to listeners.
*/
#triggerEvent(type, payload) {
if (typeof type !== 'string') throw new TypeError('`type` must be a string.');
if (typeof payload !== 'object' || payload === null)
throw new TypeError('`payload` must be an object.');
if (this.#events[type]) {
for (const cb of this.#events[type]) cb(payload);
}
}
/**
* Unregisters a specific callback for the given event type.
* @param {EventsType} eventType - The event type to remove from.
* @param {OnEvent} callback - The callback function to remove.
*/
off(eventType, callback) {
if (typeof eventType !== 'string') throw new TypeError('`eventType` must be a string.');
if (typeof callback !== 'function') throw new TypeError('`callback` must be a function.');
if (!this.#events[eventType]) return;
const list = this.#events[eventType];
const index = list.indexOf(callback);
if (index !== -1) list.splice(index, 1);
}
/**
* Unregisters all callbacks for the given event type.
* @param {EventsType} eventType - The event type to clear.
*/
offAll(eventType) {
if (typeof eventType !== 'string') throw new TypeError('`eventType` must be a string.');
if (!this.#events[eventType]) return;
this.#events[eventType] = [];
}
/**
* Returns a shallow copy of the callbacks for a given event type.
* @param {EventsType} eventType - The event type to clone.
* @returns {OnEvent[]} A cloned array of callback functions.
*/
cloneEventCallbacks(eventType) {
if (typeof eventType !== 'string') throw new TypeError('`eventType` must be a string.');
if (!this.#events[eventType]) return [];
return [...this.#events[eventType]];
}
/**
* Registers a callback to be executed when an item is added.
* @param {AddItemEvent} callback - Function receiving the event payload.
*/
onAddItem(callback) {
if (typeof callback !== 'function') throw new TypeError('`callback` must be a function.');
this.#events.add.push(callback);
}
/**
* Registers a callback to be executed when an item is removed.
* @param {SetItemEvent} callback - Function receiving the event payload.
*/
onSetItem(callback) {
if (typeof callback !== 'function') throw new TypeError('`callback` must be a function.');
this.#events.set.push(callback);
}
/**
* Registers a callback to be executed when an item is removed.
* @param {RemoveItemEvent} callback - Function receiving the event payload.
*/
onRemoveItem(callback) {
if (typeof callback !== 'function') throw new TypeError('`callback` must be a function.');
this.#events.remove.push(callback);
}
/**
* Registers a callback to be executed when an item is used.
* @param {UseItemEvent} callback - Function receiving the event payload.
*/
onUseItem(callback) {
if (typeof callback !== 'function') throw new TypeError('`callback` must be a function.');
this.#events.use.push(callback);
}
/////////////////////////////////////////////////////////////////
/**
* Removes all unnecessary `null` values from the inventory, compacting the slots.
* Preserves the relative order of items and does not modify metadata.
*/
compactInventory() {
this.#items = this.#items.filter((i, index) => {
const result = i !== null;
if (!result)
this.#triggerEvent('remove', {
index,
item: null,
isCollection: true,
specialSlot: null,
remove: () => undefined,
});
return result;
});
}
/////////////////////////////////////////////////////////////////
/**
* Adds an item to the inventory, respecting stackability rules, stack limits, and metadata matching.
* If the item cannot be fully added (e.g., due to stack limits), the remaining quantity is returned.
*
* @param {Object} options - Item addition configuration.
* @param {string} options.itemId - ID of the item to add.
* @param {boolean} [options.forceSpace=false] - Forces the item to be added even if space or stack limits would normally prevent it.
* @param {number} [options.quantity=1] - Quantity to add.
* @param {InventoryMetadata} [options.metadata={}] - Instance-specific metadata (must match for stacking).
* @returns {AddItemResult} Quantity that could not be added (0 if all were added).
* @throws {Error} If the item is not registered.
*/
addItem({ itemId, quantity = 1, metadata = {}, forceSpace = false }) {
if (typeof itemId !== 'string') throw new TypeError('`itemId` must be a string.');
if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity <= 0)
throw new TypeError('`quantity` must be a positive number.');
if (typeof metadata !== 'object' || metadata === null)
throw new TypeError('`metadata` must be an object.');
if (typeof forceSpace !== 'boolean') throw new TypeError('`forceSpace` must be a boolean.');
const def = TinyInventory.getItem(itemId);
let remaining = quantity;
const maxStack = def.maxStack <= this.#maxStack ? def.maxStack : this.#maxStack;
/** @type {{ index: number; quantity: number }[]} */
const placesAdded = [];
/**
* @param {InventoryMetadata} a
* @param {InventoryMetadata} b
*/
const metadataEquals = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// Step 1: Fill existing stacks first
let madeProgress = true;
while (remaining > 0 && madeProgress) {
madeProgress = false;
for (const index in this.#items) {
const existing = this.#items[index];
if (
existing &&
existing.id === itemId &&
existing.quantity < maxStack &&
metadataEquals(existing.metadata, metadata)
) {
const canAdd = Math.min(maxStack - existing.quantity, remaining);
if (!forceSpace && !this.hasSpace({ weight: def.weight * canAdd, sizeLength: canAdd }))
continue;
existing.quantity += canAdd;
remaining -= canAdd;
madeProgress = true;
const indexInt = Number(index);
const placeId = placesAdded.findIndex((data) => data.index === indexInt);
if (placeId < 0) placesAdded.push({ index: indexInt, quantity: canAdd });
else placesAdded[placeId].quantity += canAdd;
this.#triggerEvent('add', {
item: this.#cloneItemData(existing),
index: indexInt,
isCollection: true,
specialSlot: null,
remove: this.#removeItemCallback({
locationType: 'normal',
slotIndex: indexInt,
forceSpace,
item: existing,
}),
});
if (remaining <= 0) break;
}
}
}
// Step 2: Place remaining into null slots first
if (remaining > 0) {
for (const index in this.#items) {
if (this.#items[index] === null) {
const stackQty = Math.min(maxStack, remaining);
if (
!forceSpace &&
!this.hasSpace({ weight: def.weight * stackQty, sizeLength: stackQty })
)
continue;
const item = { id: itemId, quantity: stackQty, metadata };
this.#items[index] = item;
remaining -= stackQty;
const indexInt = Number(index);
const placeId = placesAdded.findIndex((data) => data.index === indexInt);
if (placeId < 0) placesAdded.push({ index: indexInt, quantity: stackQty });
else placesAdded[placeId].quantity += stackQty;
this.#triggerEvent('add', {
item: this.#cloneItemData(item),
index: indexInt,
isCollection: true,
specialSlot: null,
remove: this.#removeItemCallback({
locationType: 'normal',
slotIndex: indexInt,
forceSpace,
item,
}),
});
if (remaining <= 0) break;
}
}
}
// Step 3: If still remaining, push new stacks to the end
while (remaining > 0) {
const stackQty = Math.min(maxStack, remaining);
if (
!forceSpace &&
!this.hasSpace({ weight: def.weight * stackQty, sizeLength: stackQty, slotsLength: 1 })
)
break;
const item = { id: itemId, quantity: stackQty, metadata };
this.#items.push(item);
const index = this.#items.length - 1;
const placeId = placesAdded.findIndex((data) => data.index === index);
if (placeId < 0) placesAdded.push({ index: index, quantity: stackQty });
else placesAdded[placeId].quantity += stackQty;
this.#triggerEvent('add', {
item: this.#cloneItemData(item),
index,
isCollection: true,
specialSlot: null,
remove: this.#removeItemCallback({
locationType: 'normal',
slotIndex: index,
forceSpace,
item,
}),
});
remaining -= stackQty;
}
// Return remaining if some quantity couldn't be added due to maxStack
return { remaining, placesAdded };
}
/**
* Gets the item stored at a specific slot.
* @param {number} slotIndex - The slot index.
* @returns {InventoryItem|null} The item object or null if empty.
* @throws {Error} If the slot index is out of bounds.
*/
getItemFrom(slotIndex) {
if (typeof slotIndex !== 'number' || !Number.isInteger(slotIndex))
throw new TypeError('`slotIndex` must be an integer.');
if (slotIndex < 0 || slotIndex >= this.#items.length)
throw new Error(`Slot index '${slotIndex}' out of bounds .`);
return this.#items[slotIndex] ? this.#cloneItemData(this.#items[slotIndex]) : null;
}
/**
* Sets an item at a specific slot index, replacing whatever was there.
* Can also be used to place `null` in the slot to clear it.
*
* @param {Object} options - Item addition configuration.
* @param {number} options.slotIndex - Index of the slot to set.
* @param {InventoryItem|null} options.item - Item to place in the slot, or null to clear.
* @param {boolean} [options.forceSpace=false] - Forces the item to be added even if space or stack limits would normally prevent it.
* @throws {Error} If the index is out of range, or item type is invalid.
*/
setItem({ slotIndex, item, forceSpace = false }) {
if (typeof slotIndex !== 'number' || !Number.isInteger(slotIndex))
throw new TypeError('`slotIndex` must be an integer.');
if (typeof forceSpace !== 'boolean') throw new TypeError('`forceSpace` must be a boolean.');
// Validate type: must be null or a proper InventoryItem object
const isInventoryItem =
item &&
typeof item === 'object' &&
typeof item.id === 'string' &&
typeof item.quantity === 'number' &&
!Number.isNaN(item.quantity) &&
Number.isFinite(item.quantity) &&
item.quantity > -1 &&
typeof item.metadata === 'object';
if (item !== null && !isInventoryItem)
throw new Error(`Invalid item type: must be null or a valid InventoryItem.`);
const def = item ? TinyInventory.#ItemRegistry.get(item.id) : null;
if (item !== null && !def)
throw new Error(`Item '${item?.id ?? 'unknown'}' not defined in registry.`);
if (def && item) {
const maxStack = def.maxStack <= this.#maxStack ? def.maxStack : this.#maxStack;
if (item.quantity > maxStack)
throw new Error(
`Item '${item.id}' exceeds max stack size. Allowed: ${maxStack}, got: ${item.quantity}.`,
);
}
if (this.#maxSlots !== null && (slotIndex < 0 || slotIndex >= this.#maxSlots))
throw new Error(`Slot index ${slotIndex} out of range.`);
// Convert the set to an array for index-based manipulation
const oldItem = this.#items[slotIndex] ?? null;
const oldItemData = oldItem ? (TinyInventory.#ItemRegistry.get(oldItem.id) ?? null) : null;
/**
* @param {InventoryItem|null} [theItem]
* @param {ItemDef|null} [itemDef]
* @returns {number}
*/
const getTotalWeight = (theItem, itemDef) =>
itemDef ? itemDef.weight * (theItem ? theItem.quantity : 0) : 0;
/**
* @param {InventoryItem|null} [theItem]
* @returns {number}
*/
const getTotalLength = (theItem) => (theItem ? theItem.quantity : 0);
if (
!forceSpace &&
!this.hasSpace({
weight: getTotalWeight(item, def) - getTotalWeight(oldItem, oldItemData),
sizeLength: getTotalLength(item) - getTotalLength(oldItem),
})
)
throw new Error('Inventory is full or overweight.');
// Fill empty slots with nulls only up to the desired index
while (this.#items.length <= slotIndex) this.#items.push(null);
// Set the slot
this.#items[slotIndex] = item;
// Cleanup unnecessary trailing nulls
this._cleanNulls();
this.#triggerEvent('set', {
index: slotIndex,
isCollection: true,
item: item ? this.#cloneItemData(item) : null,
specialSlot: null,
remove: item
? this.#removeItemCallback({
locationType: 'normal',
slotIndex,
forceSpace,
item,
})
: () => undefined,
});
}
/**
* Deletes an item from a specific slot by setting it to null.
*
* @param {number} slotIndex - Index of the slot to delete from.
* @param {boolean} [forceSpace=false] - Forces the item to be added even if space or stack limits would normally prevent it.
*/
deleteItem(slotIndex, forceSpace = false) {
if (typeof slotIndex !== 'number' || !Number.isInteger(slotIndex))
throw new TypeError('`slotIndex` must be an integer.');
if (typeof forceSpace !== 'boolean') throw new TypeError('`forceSpace` must be a boolean.');
this.setItem({ slotIndex, item: null, forceSpace });
}
/**
* Moves an item from one slot to another.
*
* @param {number} fromIndex - Source slot index.
* @param {number} toIndex - Target slot index.
* @param {boolean} [forceSpace=false] - Forces the item to be added even if space or stack limits would normally prevent it.
* @throws {Error} If the source slot is empty or the move is invalid.
*/
moveItem(fromIndex, toIndex, forceSpace = false) {
if (typeof fromIndex !== 'number' || !Number.isInteger(fromIndex))
throw new TypeError('`fromIndex` must be an integer.');
if (typeof toIndex !== 'number' || !Number.isInteger(toIndex))
throw new TypeError('`toIndex` must be an integer.');
if (typeof forceSpace !== 'boolean') throw new TypeError('`forceSpace` must be a boolean.');
const item = this.#items[fromIndex];
if (!item) throw new Error(`No item found in slot ${fromIndex}.`);
// Place the item in the new slot
this.setItem({ slotIndex: toIndex, item, forceSpace });
// Clear the original slot
this.setItem({ slotIndex: fromIndex, item: null, forceSpace });
}
/**
* Removes a given quantity of an item from the inventory, including special slots.
* @param {Object} settings - Removal configuration.
* @param {string} settings.itemId - ID of the item to remove.
* @param {InventoryMetadata|null} [settings.metadata={}] - Metadata to match when removing (e.g., durability, enchantments).
* @param {number} [settings.quantity=1] - Quantity to remove.
* @returns {boolean} True if fully removed; false if insufficient quantity.
*/
removeItem({ itemId, metadata = null, quantity = 1 }) {
if (typeof itemId !== 'string') throw new TypeError('`itemId` must be a string.');
if (metadata !== null && typeof metadata !== 'object')
throw new TypeError('`metadata` must be an object or null.');
if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity <= 0)
throw new TypeError('`quantity` must be a positive number.');
let remaining = quantity;
/**
* @param {InventoryMetadata} a
* @param {InventoryMetadata} b
*/
const metadataEquals = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// Remove from inventory first
for (let index = 0; index < this.#items.length; index++) {
const item = this.#items[index];
if (
item &&
item.id === itemId &&
(metadata === null || metadataEquals(item.metadata, metadata))
) {
const removeQty = Math.min(item.quantity, remaining);
item.quantity -= removeQty;
remaining -= removeQty;
const indexInt = Number(index);
if (item.quantity <= 0) this.#items[index] = null;
if (remaining <= 0) {
this._cleanNulls();
this.#triggerEvent('remove', {
index: indexInt,
item: this.#cloneItemData(item),
isCollection: true,
specialSlot: null,
remove: this.#removeItemCallback({
locationType: 'normal',
slotIndex: indexInt,
item,
}),
});
return true;
}
}
}
this._cleanNulls();
// If not enough removed, check special slots
this.#specialSlots.forEach((slot, key) => {
if (
remaining > 0 &&
slot.item &&
slot.item.id === itemId &&
(metadata === null || metadataEquals(slot.item.metadata, metadata))
) {
const removeQty = Math.min(slot.item.quantity, remaining);
slot.item.quantity -= removeQty;
if (slot.item.quantity < 0) slot.item.quantity = 0;
remaining -= removeQty;
if (slot.item.quantity <= 0) slot.item = null;
this.#specialSlots.set(key, slot);
this.#triggerEvent('remove', {
index: null,
item: slot.item ? this.#cloneItemData(slot.item) : null,
isCollection: false,
specialSlot: key,
remove: slot.item
? this.#removeItemCallback({
locationType: 'special',
specialSlot: key,
item: slot.item,
})
: () => undefined,
});
}
});
return remaining <= 0;
}
/////////////////////////////////////////////////////////////////
/**
* Creates a callback that removes an item from a normal slot or a special slot.
* The callback decrements the quantity or clears the slot when empty.
*
* @param {Object} config - Removal configuration.
* @param {'special'|'normal'} config.locationType - Type of slot where the item resides.
* @param {InventoryItem} config.item - Item to remove.
* @param {string} [config.specialSlot] - ID of the special slot if locationType is 'special'.
* @param {number} [config.slotIndex] - Index of the slot if locationType is 'normal'.
* @param {boolean} [config.forceSpace=false] - Whether to force the slot update even if blocked by space restrictions.
* @returns {(forceSpace?: boolean) => void} A callback function that executes the removal.
*/
#removeItemCallback({ locationType, specialSlot, slotIndex, item, forceSpace = false }) {
if (locationType !== 'normal' && locationType !== 'special')
throw new TypeError("`locationType` must be 'normal' or 'special'.");
if (typeof forceSpace !== 'boolean') throw new TypeError('`forceSpace` must be boolean.');
if (!item || typeof item !== 'object')
throw new TypeError('`item` must be an InventoryItem object.');
if (locationType === 'special' && specialSlot && typeof specialSlot !== 'string')
throw new TypeError("`specialSlot` must be a string when locationType is 'special'.");
if (locationType === 'normal' && typeof slotIndex !== 'number')
throw new TypeError("`slotIndex` must be a number when locationType is 'normal'.");
return (fs = forceSpace) => {
if (locationType === 'special' && specialSlot) {
const slot = this.#specialSlots.get(specialSlot);
if (!slot?.item) throw new Error(`Special slot '${specialSlot}' is empty.`);
if (slot.item.quantity > 1) {
slot.item.quantity -= 1;
this.#specialSlots.set(specialSlot, slot);
} else this.setSpecialSlot({ slotId: specialSlot, item: null, forceSpace: fs });
} else if (typeof slotIndex === 'number') {
if (item.quantity > 1) {
this.setItem({
slotIndex,
item: { ...item, quantity: item.quantity - 1 },
forceSpace: fs,
});
} else this.setItem({ slotIndex, item: null, forceSpace: fs });
} else
throw new Error(`Invalid remove operation: no valid slotIndex or specialSlot provided.`);
};
}
/**
* Uses an item from a specific slot, or special slot,
* triggering its `onUse` callback if defined.
* Automatically removes the item if `remove()` is called inside the callback.
*
* @param {Object} location - Item location data.
* @param {number} [location.slotIndex] - Index in inventory.
* @param {string} [location.specialSlot] - Name of the special slot (if applicable).
* @param {boolean} [location.forceSpace=false] - Forces the item to be added even if space or stack limits would normally prevent it.
* @param {...any} args - Additional arguments passed to the `onUse` handler.
* @returns {any} - The return value of the `onUse` callback, or `null` if no callback exists.
* @throws {Error} - If the item is not found in the specified location.
*/
useItem({ slotIndex, specialSlot, forceSpace = false }, ...args) {
if (slotIndex !== undefined && (typeof slotIndex !== 'number' || !Number.isInteger(slotIndex)))
throw new TypeError('`slotIndex` must be an integer if provided.');
if (specialSlot !== undefined && typeof specialSlot !== 'string')
throw new TypeError('`specialSlot` must be a string if provided.');
if (typeof forceSpace !== 'boolean') throw new TypeError('`forceSpace` must be boolean.');
/** @type {InventoryItem|null} */
let item = null;
/** @type {'normal'|'special'} */
let locationType = 'normal'; // "normal"| "special"
/** @type {InvSlots | null} */
let collection = null;
// Get item
if (specialSlot) {
if (!this.#specialSlots.has(specialSlot))
throw new Error(`Special slot '${specialSlot}' not found.`);
// @ts-ignore
item = this.#specialSlots.get(specialSlot).item;
locationType = 'special';
} else {
collection = this.#items;
item = collection[slotIndex ?? -1] ?? null;
locationType = 'normal';
}
// Check item
if (!item) {
throw new Error(
locationType === 'special'
? `No item found in special slot '${specialSlot}'.`
: `No item found in slot ${slotIndex} of inventory.`,
);
}
// Get item info
const def = TinyInventory.getItem(item.id);
if (def.onUse) {
const onUse = {
inventory: this,
item: this.#cloneItemData(item),
index: slotIndex ?? null,
specialSlot: specialSlot ?? null,
isCollection: !!collection,
itemDef: def,
remove: this.#removeItemCallback({
locationType,
specialSlot,
slotIndex,
forceSpace,
item,
}),
...args,
};
const result = def.onUse(onUse);
this.#triggerEvent('use', onUse);
return result;
}
return null;
}
/////////////////////////////////////////////////////////////////
/**
* Checks if a special slot with the given ID exists in the inventory.
* @param {string} slotId - ID of the special slot.
* @returns {boolean} True if the slot exists, otherwise false.
*/
hasSpecialSlot(slotId) {
if (typeof slotId !== 'string') throw new TypeError('`slotId` must be a string.');
return this.#specialSlots.has(slotId);
}
/**
* Gets the item stored in a special slot.
* @param {string} slotId - The special slot ID.
* @returns {InventoryItem|null} The item object or null if empty.
* @throws {Error} If the special slot does not exist.
*/
getSpecialItem(slotId) {
if (typeof slotId !== 'string') throw new TypeError('`slotId` must be a string.');
if (!this.#specialSlots.has(slotId))
throw new Error(`Special slot '${slotId}' does not exist.`);
const slot = this.#specialSlots.get(slotId);
return slot?.item ? this.#cloneItemData(slot.item) : null;
}
/**
* Gets the type of a special slot.
* @param {string} slotId - The special slot ID.
* @returns {string|null} The slot type, or null if no type restriction.
* @throws {Error} If the special slot does not exist.
*/
getSpecialSlotType(slotId) {
if (typeof slotId !== 'string') throw new TypeError('`slotId` must be a string.');
if (!this.#specialSlots.has(slotId))
throw new Error(`Special slot '${slotId}' does not exist.`);
const slot = this.#specialSlots.get(slotId);
return slot?.type ?? null;
}
/**
* Sets or clears an item in a special slot.
*
* @param {Object} options - Item addition configuration.
* @param {string} options.slotId - Name of the special slot.
* @param {InventoryItem|null} options.item - Item to place, or null to clear.
* @param {boolean} [options.forceSpace=false] - Forces the item to be added even if space or stack limits would normally prevent it.
* @throws {Error} If the slot does not exist, or item is invalid.
*/
setSpecialSlot({ slotId, item, forceSpace = false }) {
if (typeof slotId !== 'string') throw new TypeError('`slotId` must be a string.');
if (typeof forceSpace !== 'boolean') throw new TypeError('`forceSpace` must be boolean.');
if (!this.#specialSlots.has(slotId)) throw new Error(`Special slot '${slotId}' not found.`);
// Validate type: must be null or a proper InventoryItem object
const isInventoryItem =
item &&
typeof item === 'object' &&
typeof item.id === 'string' &&
typeof item.quantity === 'number' &&
!Number.isNaN(item.quantity) &&
Number.isFinite(item.quantity) &&
item.quantity > -1 &&
typeof item.metadata === 'object';
if (item !== null && !isInventoryItem)
throw new Error(`Invalid item type: must be null or a valid InventoryItem.`);
const def = item ? TinyInventory.#ItemRegistry.get(item.id) : null;
if (item !== null && !def)
throw new Error(`Item '${item?.id ?? 'unknown'}' not defined in registry.`);
// Slot check
const slot = this.#specialSlots.get(slotId);
if (!slot) throw new Error(`Special slot ${slotId} out of range for slot '${slotId}'.`);
// Weight check
const oldItemData = slot.item ? TinyInventory.#ItemRegistry.get(slot.item.id) : null;
/**
* @param {InventoryItem|null} [theItem]
* @param {ItemDef|null} [itemDef]
* @returns {number}
*/
const getTotalWeight = (theItem, itemDef) => (itemDef ? itemDef.weight * (theItem ? 1 : 0) : 0);
/**
* @param {InventoryItem|null} [theItem]
* @returns {number}
*/
const getTotalLength = (theItem) => (theItem ? 1 : 0);
if (
!forceSpace &&
!this.hasSpace({
weight: getTotalWeight(item, def) - getTotalWeight(slot.item, oldItemData),
sizeLength: getTotalLength(item) - getTotalLength(slot.item),
})
)
throw new Error('Inventory is full or overweight.');
// Set or clear
slot.item = item;
this.#triggerEvent('set', {
index: null,
item: item ? this.#cloneItemData(item) : null,
isCollection: false,
specialSlot: slotId,
remove: item
? this.#removeItemCallback({
locationType: 'special',
specialSlot: slotId,
forceSpace,
item,
})
: () => undefined,
});
}
/**
* Deletes an item from a specific special slot by setting it to null.
*
* @param {string} slotId - Special slot to delete from.
* @param {boolean} [forceSpace=false] - Forces the item to be added even if space or stack limits would normally prevent it.
*/
deleteSpecialItem(slotId, forceSpace = false) {
if (typeof slotId !== 'string') throw new TypeError('`slotId` m