//-----------------------------------------------------------------------------
// Inventory
//-----------------------------------------------------------------------------
/**
* @class RezInventory
* @extends RezBasicObject
* @category Elements
* @description Manages a collection of slots that can hold items.
*
* An inventory is a container system that organizes items into typed slots.
* Each slot can accept items of a specific type and may have capacity limits.
* Inventories can be owned by actors, enabling equipment systems with effects.
*
* Key features:
* - **Typed Slots**: Each slot accepts only items of a matching type
* - **Capacity**: Slots can have size limits based on item sizes
* - **Weight**: Inventories can have an overall `max_weight`, checked against
* the total `weight` of all items they contain
* - **Effects**: Items can apply effects to the inventory's owner when inserted
* - **Events**: Triggers events on insert/remove for items, slots, and inventory
*
* Slots are defined as a binding list on the `slots` attribute where each
* binding key (prefix) is the slot position name and the value is a reference
* to a `@slot` element that defines the slot's type configuration. Multiple
* positions can share the same slot type definition.
*
* **Define in Rez:**
* <pre><code>
* @inventory player_inv {
* slots: [weapon: #s_weapon, armor: #s_armor]
* initial_weapon: [#item_sword]
* }
* </code></pre>
*
* @example <caption>Add an item at runtime</caption>
* const inv = $("player_inv");
* if(inv.canAddItemForSlot("weapon", "item_axe").result) {
* inv.addItemToSlot("weapon", "item_axe");
* }
*/
class RezInventory extends RezBasicObject {
constructor(id, attributes) {
super("inventory", id, attributes);
}
/**
* @function elementInitializer
* @memberof RezInventory
* @description called as part of the init process this creates the initial inventory slots
*/
elementInitializer() {
this.resolveOwner();
this.addInitialContents();
this.addInitialEnabledStates();
}
/**
* @function resolveOwner
* @memberof RezInventory
* @description If `owner_id` was not authored directly, find the actor that
* declares this inventory as its `container_id` and record it as `owner_id`,
* so that `inventory.owner` is reliably available (for the actor veto, effect
* application, and insert/remove event handlers). Author ownership in a single
* place (`actor.container_id`); an explicitly authored `owner_id` still wins.
* Safe at init time because all game objects are registered before any object
* is initialised.
*/
resolveOwner() {
if(this.hasValue("owner_id")) return;
const owner = this.game.filterObjects(
(o) => o.element === "actor" && o.getAttributeValue("container_id", null) === this.id
)[0];
if(owner) {
this.setAttribute("owner_id", owner.id);
}
}
addInitialContents() {
for(const prefix of Object.keys(this.getAttributeValue("slots"))) {
const initialContents = this.getAttributeValue(`initial_${prefix}`, []);
for(const contentId of initialContents) {
const decision = this.addItemToSlot(prefix, contentId);
if(!decision.result) {
throw new Error(`Inventory |${this.id}|: cannot place initial item |${contentId}| in slot |${prefix}|: ${decision.reason}`);
}
}
}
}
addInitialEnabledStates() {
for(const prefix of Object.keys(this.getAttributeValue("slots"))) {
const enabled = this.getAttributeValue(`initial_${prefix}_enabled`, true);
this.setAttribute(`${prefix}_enabled`, enabled);
}
}
/**
* @function addSlot
* @memberof RezInventory
* @param {string} slotBinding - the binding prefix for the slot position
* @param {string} slotId - the slot element id (unused but kept for API clarity)
* @description add a new slot to the inventory
*/
addSlot(slotBinding, _slotId) {
const attrName = `${slotBinding}_contents`;
if(!this.hasAttribute(attrName)) {
this.setAttribute(attrName, []);
this.createStaticProperty(attrName);
}
}
/**
* @function getSlots
* @memberof RezInventory
* @returns {array} array of {prefix, slot} objects for every slot position in this inventory
* @example
* for(const {prefix, slot} of inv.getSlots()) {
* const available = inv.isSlotAvailable(prefix);
* // render slot UI using prefix (binding name) and slot (RezSlot object with name, accepts, etc.)
* }
*/
getSlots() {
return Object.entries(this.getAttributeValue("slots")).map(([prefix, slotId]) => ({
prefix,
slot: $t(slotId, "slot", true)
}));
}
/**
* @function getSlot
* @memberof RezInventory
* @param {string} name - the binding prefix identifying the slot position
* @returns {object} reference to the slot element for this binding, or throws
* if the binding does not exist in this inventory.
*/
getSlot(name) {
const slotId = this.getAttributeValue("slots")[name];
if(!slotId) {
throw new Error(`Inventory |${this.id}| does not have slot binding |${name}|!`);
}
return $t(slotId, "slot", true);
}
/**
* @function getFirstItemForSlot
* @memberof RezInventory
* @param {string} slotBinding
* @returns {string} id of first item in the slot
*/
getFirstItemForSlot(slotBinding) {
return this.getItemsForSlot(slotBinding)[0];
}
/**
* @function getItemsForSlot
* @memberof RezInventory
* @param {string} slotBinding
* @returns {array} contents of the specified slot
*/
getItemsForSlot(slotBinding) {
this.getSlot(slotBinding); // validates binding exists
return this.getAttribute(`${slotBinding}_contents`);
}
/**
* @function slotIsOccupied
* @memberof RezInventory
* @param {string} slotBinding
* @returns {boolean} true if there is at least one item in the slot
*/
slotIsOccupied(slotBinding) {
return this.countItemsInSlot(slotBinding) > 0;
}
/**
* @function isSlotAvailable
* @memberof RezInventory
* @param {string} slotBinding - the binding prefix identifying the slot position
* @returns {boolean} true if the slot is not blocked by any exclusion rule
* @description Useful for greying out slot categories in inventory UI.
*/
isSlotAvailable(slotBinding) {
return !this.isBlockedByExclusion(slotBinding);
}
/**
* @function isSlotEnabled
* @memberof RezInventory
* @param {string} slotBinding - the binding prefix identifying the slot position
* @returns {boolean} true if the slot has been enabled
* @description Slots default to enabled. Use `initial_{prefix}_enabled: false` in the
* inventory definition to start a slot disabled, then call `enableSlot()` at runtime
* when the player unlocks it (e.g. on reaching a required level).
* @example <caption>Define a locked slot in Rez</caption>
* // @inventory player_equip {
* // slots: [ring1: #s_ring, ring2: #s_ring]
* // initial_ring2_enabled: false
* // }
* @example <caption>Unlock at runtime</caption>
* if(player.level >= 5) {
* $("player_equip").enableSlot("ring2");
* }
* @example <caption>Check in UI</caption>
* for(const {prefix, slot} of inv.getSlots()) {
* const enabled = inv.isSlotEnabled(prefix);
* const available = inv.isSlotAvailable(prefix);
* // enabled=false → locked; available=false → excluded by another equipped item
* }
*/
isSlotEnabled(slotBinding) {
return this.getAttributeValue(`${slotBinding}_enabled`, true);
}
/**
* @function enableSlot
* @memberof RezInventory
* @param {string} slotBinding - the binding prefix identifying the slot position
* @description Enables the slot so items can be added to it.
* @example
* $("player_equip").enableSlot("ring2");
*/
enableSlot(slotBinding) {
this.getSlot(slotBinding); // validates binding exists
this.setAttribute(`${slotBinding}_enabled`, true);
}
/**
* @function disableSlot
* @memberof RezInventory
* @param {string} slotBinding - the binding prefix identifying the slot position
* @description Disables the slot so no further items can be added to it.
* @example
* $("player_equip").disableSlot("ring2");
*/
disableSlot(slotBinding) {
this.getSlot(slotBinding); // validates binding exists
this.setAttribute(`${slotBinding}_enabled`, false);
}
/**
* @function _writeSlotContents
* @memberof RezInventory
* @private
* @param {string} slotBinding
* @param {array} itemIds array of item id's
* @description Low-level, unguarded write of a slot's contents array. Does NOT
* validate items, fire events, or reconcile effects. Internal use only; author
* code should use setSlot/setItemsForSlot/addItemToSlot which go through validation.
*/
_writeSlotContents(slotBinding, itemIds) {
this.getSlot(slotBinding); // validates binding exists
this.setAttribute(`${slotBinding}_contents`, itemIds);
}
/**
* @function setSlot
* @memberof RezInventory
* @param {string} slotBinding
* @param {array} itemIds array of item id's
* @returns {RezDecision[]} a decision for each item added (see setItemsForSlot)
* @description Replaces the slot's contents with the given items. Existing
* occupants are removed (firing remove events and releasing their effects) and
* each new item is added through the validated path. Alias for setItemsForSlot.
*/
setSlot(slotBinding, itemIds) {
return this.setItemsForSlot(slotBinding, itemIds);
}
/**
* @function appendItemToSlot
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
* @returns {RezDecision} the decision from addItemToSlot
* @description Appends the given item to the given slot through the validated
* add path (fires events, applies effects, and may refuse). Retained for API
* compatibility; previously this was an unconditional raw push.
*/
appendItemToSlot(slotBinding, itemId) {
return this.addItemToSlot(slotBinding, itemId);
}
/**
* @function appendToSlot
* @memberof RezInventory
* @param {string} slotBinding
* @param {string|array} itemOrItems either an item_id or array of item_id's to append to the slot
* @returns {RezDecision[]} a decision for each item, in order
* @description add either a single item_id or an array of item_ids to the slot,
* each through the validated add path.
*/
appendToSlot(slotBinding, itemOrItems) {
return ensureArray(itemOrItems).map((itemId) => {
return this.addItemToSlot(slotBinding, itemId);
});
}
/**
* @function setItemForSlot
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
* @returns {RezDecision[]} a decision for the added item (see setItemsForSlot)
* @description replaces any existing item content for the slot with this item
*/
setItemForSlot(slotBinding, itemId) {
return this.setItemsForSlot(slotBinding, [itemId]);
}
/**
* @function setItemsForSlot
* @memberof RezInventory
* @param {string} slotBinding
* @param {array} items array of item ids
* @returns {RezDecision[]} a decision for each item added, in order
* @description Replaces any existing item content for the slot with these items.
* Existing occupants are removed first (firing remove events and releasing their
* effects), then each new item is added through the validated path, so an item
* the slot can't accept is refused (and reflected in its decision) rather than
* silently set.
*/
setItemsForSlot(slotBinding, items) {
const current = [...this.getItemsForSlot(slotBinding)];
current.forEach((itemId) => this._removeItem(slotBinding, itemId));
return ensureArray(items).map((itemId) => this.addItemToSlot(slotBinding, itemId));
}
/**
* @function countItemsInSlot
* @memberof RezInventory
* @param {string} slotBinding
* @returns {integer} number of items in the given slot
*/
countItemsInSlot(slotBinding) {
return this.getItemsForSlot(slotBinding).length;
}
/**
* @function slotContainsItem
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
* @returns {boolean} true if the item_id is in the slot
*/
slotContainsItem(slotBinding, itemId) {
return this.getItemsForSlot(slotBinding).some((anItemId) => itemId === anItemId);
}
/**
* @function containsItem
* @memberof RezInventory
* @param {string} itemId
* @returns {string|undefined} binding prefix of the slot containing the item, or undefined
*/
containsItem(itemId) {
for(const prefix of Object.keys(this.getAttributeValue("slots"))) {
if(this.slotContainsItem(prefix, itemId)) return prefix;
}
return undefined;
}
/**
* @function itemFitsInSlot
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
* @returns {boolean} true if the item will fit with any other contents of the slot
* @description If the slot has `has_capacity: true`, items count towards
* `slot.capacity` using their `size` attribute, defaulting to `1` if unset.
* This means `capacity: 1` naturally limits a slot to a single item unless
* an item explicitly opts out with `size: 0` (e.g. a weightless item) or
* takes up more room with `size: 2+`. Slots without `has_capacity: true`
* accept any number of items.
*/
itemFitsInSlot(slotBinding, itemId) {
const slot = this.getSlot(slotBinding);
if(!slot.has_capacity) return true;
const itemSize = $(itemId).getAttributeValue("size", 1);
const usedCapacity = this.getItemsForSlot(slotBinding).reduce((amount, id) => {
return amount + $(id).getAttributeValue("size", 1);
}, 0);
return usedCapacity + itemSize <= slot.capacity;
}
/**
* @function totalWeight
* @memberof RezInventory
* @returns {number} sum of the `weight` attribute of every item in every slot
*/
totalWeight() {
return Object.keys(this.getAttributeValue("slots")).reduce((total, prefix) => {
return total + this.getItemsForSlot(prefix).reduce((amount, itemId) => {
return amount + $(itemId).getAttributeValue("weight", 0);
}, 0);
}, 0);
}
/**
* @function maxWeight
* @memberof RezInventory
* @returns {number} the `max_weight` attribute, or `Infinity` if not set
*/
maxWeight() {
return this.getAttributeValue("max_weight", Infinity);
}
/**
* @function remainingWeight
* @memberof RezInventory
* @returns {number} how much more weight this inventory can carry before reaching `max_weight`
*/
remainingWeight() {
return this.maxWeight() - this.totalWeight();
}
/**
* @function isOverweight
* @memberof RezInventory
* @returns {boolean} true if the inventory's total weight exceeds `max_weight`
*/
isOverweight() {
return this.totalWeight() > this.maxWeight();
}
/**
* @function itemFitsWeight
* @memberof RezInventory
* @param {string} itemId
* @returns {boolean} true if adding this item would not exceed `max_weight`
*/
itemFitsWeight(itemId) {
return this.totalWeight() + $(itemId).getAttributeValue("weight", 0) <= this.maxWeight();
}
/**
* @function isBlockedByExclusion
* @memberof RezInventory
* @param {string} slotBinding
* @returns {boolean} true if an occupied slot excludes this slot, or this slot excludes an occupied slot
*/
isBlockedByExclusion(slotBinding) {
const slots = this.getAttributeValue("slots");
const targetSlotId = slots[slotBinding];
const targetExcludes = this.getSlot(slotBinding).getAttributeValue("excludes", new Set());
for(const [prefix, slotId] of Object.entries(slots)) {
if(prefix === slotBinding) continue;
if(!this.slotIsOccupied(prefix)) continue;
// Direction 1: target slot excludes this occupied position's slot type
if([...targetExcludes].some(ref => ref.$ref === slotId)) {
return true;
}
// Direction 2: this occupied position's slot type excludes the target slot
const otherExcludes = $t(slotId, "slot", true).getAttributeValue("excludes", new Set());
if([...otherExcludes].some(ref => ref.$ref === targetSlotId)) {
return true;
}
}
return false;
}
/**
* @function slotAcceptsItem
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
* @returns {boolean} true if the given item has a type that this slot accepts
*/
slotAcceptsItem(slotBinding, itemId) {
const slot = this.getSlot(slotBinding);
const accepts = slot.getAttributeValue("accepts");
const item = $(itemId);
const type = item.getAttributeValue("type");
return Rez.isTypeOf(type, accepts);
}
/**
* @function canAddItemForSlot
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
* @returns {RezDecision} decision object with result
*/
canAddItemForSlot(slotBinding, itemId) {
const decision = new RezDecision("canItemForSlot");
if(!this.isSlotEnabled(slotBinding)) {
decision.no("slot is not enabled").setData("failed_on", "enabled");
} else if(!this.slotAcceptsItem(slotBinding, itemId)) {
decision
.no("slot doesn't take this kind of item")
.setData("failed_on", "accepts");
} else if(this.isBlockedByExclusion(slotBinding)) {
decision.no("slot is blocked by exclusion").setData("failed_on", "excludes");
} else if(!this.itemFitsInSlot(slotBinding, itemId)) {
decision.no("does not fit").setData("failed_on", "capacity");
} else if(!this.itemFitsWeight(itemId)) {
decision.no("too heavy").setData("failed_on", "weight");
} else if(this.owner != null) {
const actorDecision = this.owner.checkItem(this.id, slotBinding, itemId);
if(actorDecision.result) {
decision.yes();
} else {
decision.no(actorDecision.reason).setData("failed_on", "actor");
}
} else {
decision.yes();
}
return decision;
}
/**
* @function canRemoveItemFromSlot
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
* @returns {RezDecision} decision object with result, defaulting to yes
*/
canRemoveItemFromSlot(slotBinding, itemId) {
const decision = new RezDecision("canRemoveItemFromSlot");
decision.defaultYes();
decision.setData("inventory_id", this.id);
decision.setData("slot_id", slotBinding);
decision.setData("item_id", itemId);
return decision;
}
/**
* @function addItemToSlot
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
* @returns {RezDecision} the decision from canAddItemForSlot; when it is yes the
* item has been inserted, otherwise the slot is unchanged and the decision carries
* the reason and `failed_on` data.
* @description Adds the given item to the given slot if `canAddItemForSlot` allows
* it, notifying inventory, slot & item and applying effects. Callers may inspect
* the returned decision; callers that ignore it simply get a no-op on refusal.
* @example
* const d = inv.addItemToSlot("weapon", "item_axe");
* if(d.wasNo) showMessage(d.reason);
*/
addItemToSlot(slotBinding, itemId) {
const decision = this.canAddItemForSlot(slotBinding, itemId);
if(decision.result) {
this._insertItem(slotBinding, itemId);
}
return decision;
}
/**
* @function _insertItem
* @memberof RezInventory
* @private
* @param {string} slotBinding
* @param {string} itemId
* @description Low-level, unguarded insert. Appends the item, fires the insert
* events on inventory, slot & item, and applies effects. Performs no acceptance
* checks beyond requiring the item to define a `type`. Internal use only.
*/
_insertItem(slotBinding, itemId) {
const item = $(itemId);
if(!item.hasAttribute("type")) {
throw new Error(`Attempt to add ${itemId} to inventory, which does not define a 'type'!`);
}
this.getItemsForSlot(slotBinding).push(itemId);
const ownerId = this.getAttributeValue("owner_id", null);
const owner = ownerId ? this.owner : null;
const slot = this.getSlot(slotBinding);
this.runEvent("insert", { slot_id: slot.id, slot_binding: slotBinding, item_id: itemId, owner_id: ownerId, owner });
slot.runEvent("insert", { inventory_id: this.id, item_id: itemId, owner_id: ownerId, owner });
item.runEvent("insert", { inventory_id: this.id, slot_id: slot.id, slot_binding: slotBinding, owner_id: ownerId, owner });
this.applyEffects(slotBinding, itemId);
}
/**
* @function shouldApplyEffects
* @memberof RezInventory
* @param {string} slotBinding
* @returns {boolean} whether effects should be applied for this slot
*/
shouldApplyEffects(slotBinding) {
if(this.owner) {
if(this.apply_effects) {
const slot = this.getSlot(slotBinding);
return slot.apply_effects;
}
return false;
}
return false;
}
/**
* @function applyEffects
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
* @returns {boolean} whether the effect was applied
*/
applyEffects(slotBinding, itemId) {
if(!this.shouldApplyEffects(slotBinding)) {
return false;
}
const item = $(itemId);
if(!item.hasAttribute("effect_ids")) {
return false;
}
for(const effectId of item.getAttributeValue("effect_ids")) {
const effect = $t(effectId, "effect");
effect.apply(this.owner_id, slotBinding, itemId);
}
return true;
}
/**
* @function removeItemFromSlot
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
* @returns {RezDecision} the decision from canRemoveItemFromSlot; when it is yes
* the item has been removed (firing remove events and releasing effects), otherwise
* the slot is unchanged and the decision carries the reason. An item that isn't in
* the slot yields a `no` decision (`failed_on: "missing"`) rather than throwing.
* @description removes the specified item from the specified inventory slot
*/
removeItemFromSlot(slotBinding, itemId) {
const decision = this.canRemoveItemFromSlot(slotBinding, itemId);
if(!this.getItemsForSlot(slotBinding).includes(itemId)) {
return decision
.no(`No item |${itemId}| in slot binding |${slotBinding}| on inventory |${this.id}|!`)
.setData("failed_on", "missing");
}
if(decision.result) {
this._removeItem(slotBinding, itemId);
}
return decision;
}
/**
* @function _removeItem
* @memberof RezInventory
* @private
* @param {string} slotBinding
* @param {string} itemId
* @description Low-level, unguarded removal. Rewrites the slot contents without the
* item, fires the remove events on slot, item & inventory, and removes effects.
* Assumes the item is present. Internal use only.
*/
_removeItem(slotBinding, itemId) {
const contents = this.getItemsForSlot(slotBinding);
this._writeSlotContents(slotBinding, contents.filter((id) => id !== itemId));
const ownerId = this.getAttributeValue("owner_id", null);
const owner = ownerId ? this.owner : null;
const slot = this.getSlot(slotBinding);
slot.runEvent("remove", { inventory_id: this.id, item_id: itemId, owner_id: ownerId, owner });
const item = $(itemId);
item.runEvent("remove", { inventory_id: this.id, slot_id: slot.id, slot_binding: slotBinding, owner_id: ownerId, owner });
this.runEvent("remove", { slot_id: slot.id, slot_binding: slotBinding, item_id: itemId, owner_id: ownerId, owner });
this.removeEffects(slotBinding, itemId);
}
/**
* @function removeEffects
* @memberof RezInventory
* @param {string} slotBinding
* @param {string} itemId
*/
removeEffects(slotBinding, itemId) {
if(!this.shouldApplyEffects(slotBinding)) {
return false;
}
const item = $(itemId);
if(!item.hasAttribute("effect_ids")) {
return false;
}
for(const effectId of item.getAttributeValue("effect_ids")) {
const effect = $t(effectId, "effect");
effect.remove(this.owner_id, slotBinding, itemId);
}
}
/**
* @function clearSlot
* @memberof RezInventory
* @param {string} slotBinding
* @description remove all items from the given slot, removing any effects granted by those items
*/
clearSlot(slotBinding) {
const items = this.getItemsForSlot(slotBinding);
items.forEach((itemId) => {
this.removeItemFromSlot(slotBinding, itemId);
});
}
}
window.Rez.RezInventory = RezInventory;
Source