From c2e9a597fa2deaae6f14ae338acbaa8dfee0926f Mon Sep 17 00:00:00 2001 From: Pabloader Date: Wed, 29 Apr 2026 12:23:49 +0000 Subject: [PATCH] Infinite inventory --- src/common/rpg/components/inventory.ts | 199 +++++++++++++++++++------ 1 file changed, 154 insertions(+), 45 deletions(-) diff --git a/src/common/rpg/components/inventory.ts b/src/common/rpg/components/inventory.ts index b955c03..e4645a8 100644 --- a/src/common/rpg/components/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -3,6 +3,7 @@ import { action } from "../utils/decorators"; import { Component, type EvalContext } from "../core/world"; import { component } from "../core/registry"; import { Stackable, Usable } from "./item"; +import { Equipment, Equippable } from "./equipment"; import { resolveVariables } from "../utils/variables"; interface SlotRecord { @@ -12,6 +13,8 @@ interface SlotRecord { } interface InventoryState { + infinite: boolean; + nextSlotId: number; // auto-increment counter for infinite mode slots: SlotRecord[]; } @@ -19,14 +22,34 @@ interface InventoryState { export class Inventory extends Component { #cachedVars: RPGVariables | null = null; - constructor(slotDefs: Array) { - super({ - slots: slotDefs.map(def => { - const slotId = typeof def === 'object' ? def.slotId : def; - const limit = typeof def === 'object' ? def.limit : undefined; - return { slotId, limit, contents: null }; - }), - }); + /** Infinite inventory — grows on demand, no slot cap. */ + constructor(); + /** N generic slots, each limited only by item maxStack. */ + constructor(count: number); + /** Named slots with optional per-slot stack limits (original behaviour). */ + constructor(slots: InventorySlotInput[]); + constructor(input?: number | InventorySlotInput[]) { + if (input === undefined) { + super({ infinite: true, nextSlotId: 0, slots: [] }); + } else if (typeof input === 'number') { + super({ + infinite: false, + nextSlotId: 0, + slots: Array.from({ length: input }, (_, i) => ({ + slotId: i, limit: undefined, contents: null, + })), + }); + } else { + super({ + infinite: false, + nextSlotId: 0, + slots: input.map(def => { + const slotId = typeof def === 'object' ? def.slotId : def; + const limit = typeof def === 'object' ? def.limit : undefined; + return { slotId, limit, contents: null }; + }), + }); + } } #slot(slotId: SlotId): SlotRecord | undefined { @@ -56,6 +79,7 @@ export class Inventory extends Component { return false; } + // ── Direct slot ─────────────────────────────────────────────────────── if (slotId !== undefined) { const slot = this.#slot(slotId); if (!slot) return false; @@ -65,45 +89,70 @@ export class Inventory extends Component { return true; } - // Two-phase: pre-check then apply - let remaining = amount; - for (const slot of this.state.slots) { - if (slot.contents === null || slot.contents.itemId === itemId) { - remaining -= Math.min(this.#roomFor(slot, itemId), remaining); - if (remaining === 0) break; + // ── Finite inventory: two-phase (check → apply) ─────────────────────── + if (!this.state.infinite) { + let canFit = 0; + for (const slot of this.state.slots) { + if (slot.contents === null || slot.contents.itemId === itemId) + canFit += this.#roomFor(slot, itemId); } - } - if (remaining > 0) return false; + if (canFit < amount) return false; - // Apply — fill existing slots for this item first, then empty ones - remaining = amount; + let remaining = amount; + const slotIds: SlotId[] = []; + for (const slot of this.state.slots) { + if (slot.contents?.itemId === itemId && remaining > 0) { + const take = Math.min(this.#roomFor(slot, itemId), remaining); + slot.contents!.amount += take; + remaining -= take; + slotIds.push(slot.slotId); + } + } + for (const slot of this.state.slots) { + if (slot.contents === null && remaining > 0) { + const take = Math.min(this.#capFor(slot, itemId), remaining); + slot.contents = { itemId, amount: take }; + remaining -= take; + slotIds.push(slot.slotId); + } + } + this.emit('add', { itemId, amount, slotIds }); + return true; + } + + // ── Infinite inventory: fill existing slots, then grow ───────────────── + let remaining = amount; const slotIds: SlotId[] = []; + for (const slot of this.state.slots) { - if (slot.contents?.itemId === itemId) { + if (slot.contents?.itemId === itemId && remaining > 0) { const take = Math.min(this.#roomFor(slot, itemId), remaining); - slot.contents.amount += take; - remaining -= take; - slotIds.push(slot.slotId); - if (remaining === 0) { - this.emit('add', { itemId, amount, slotIds }); - return true; + if (take > 0) { + slot.contents!.amount += take; + remaining -= take; + slotIds.push(slot.slotId); } } } for (const slot of this.state.slots) { - if (slot.contents === null) { + if (slot.contents === null && remaining > 0) { const take = Math.min(this.#capFor(slot, itemId), remaining); slot.contents = { itemId, amount: take }; remaining -= take; slotIds.push(slot.slotId); - if (remaining === 0) { - this.emit('add', { itemId, amount, slotIds }); - return true; - } } } + while (remaining > 0) { + const newSlot: SlotRecord = { slotId: this.state.nextSlotId++, limit: undefined, contents: null }; + this.state.slots.push(newSlot); + const take = Math.min(this.#capFor(newSlot, itemId), remaining); + newSlot.contents = { itemId, amount: take }; + remaining -= take; + slotIds.push(newSlot.slotId); + } - return remaining === 0; + this.emit('add', { itemId, amount, slotIds }); + return true; } @action @@ -127,27 +176,71 @@ export class Inventory extends Component { let remaining = amount; const slotIds: SlotId[] = []; for (const slot of this.state.slots) { - if (slot.contents?.itemId === itemId) { + if (slot.contents?.itemId === itemId && remaining > 0) { const take = Math.min(slot.contents.amount, remaining); slot.contents.amount -= take; if (slot.contents.amount === 0) slot.contents = null; remaining -= take; slotIds.push(slot.slotId); - if (remaining === 0) { - this.emit('remove', { itemId, amount, slotIds }); - return true; - } } } - return false; + this.emit('remove', { itemId, amount, slotIds }); + return true; } + /** + * Equip an item from this inventory onto the entity's `Equipment` component. + * `slotId` specifies which inventory slot to equip from (otherwise any slot is used). + * `slotName` specifies the equipment body slot (otherwise auto-detected by item type). + */ @action - async use(itemId?: string, ctx?: EvalContext): Promise { - if (!itemId) return false; + equip(arg: string | { itemId?: string; slotId?: SlotId; slotName?: string }): boolean { + const resolved = this.#resolveItem(arg); + if (!resolved) return false; + const { itemId, slotId } = resolved; + const slotName = typeof arg === 'object' ? arg.slotName : undefined; - if (this.getAmount(itemId) === 0) { + if (this.getAmount(itemId, slotId) === 0) { + console.warn(`[Inventory] equip: item '${itemId}' not in inventory`); + return false; + } + + const equipment = this.entity.get(Equipment); + if (!equipment) { + console.warn(`[Inventory] equip: entity has no Equipment component`); + return false; + } + + let resolvedSlot = slotName; + if (resolvedSlot === undefined) { + const equippable = this.entity.world.getEntity(itemId)?.get(Equippable); + if (!equippable) { + console.warn(`[Inventory] equip: item '${itemId}' has no Equippable component`); + return false; + } + const found = equipment.findCompatibleSlot(equippable.slotType); + if (!found) { + console.warn(`[Inventory] equip: no compatible slot for '${itemId}'`); + return false; + } + resolvedSlot = found; + } + + return equipment.equip({ slotName: resolvedSlot, itemId }); + } + + /** + * Use an item from this inventory. + * `slotId` specifies which inventory slot to use from (otherwise any slot is used). + */ + @action + async use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): Promise { + const resolved = this.#resolveItem(arg); + if (!resolved) return false; + const { itemId, slotId } = resolved; + + if (this.getAmount(itemId, slotId) === 0) { console.warn(`[Inventory] use: item '${itemId}' not in inventory`); return false; } @@ -164,13 +257,29 @@ export class Inventory extends Component { return false; } - if (usable.consumeOnUse) { - this.remove({ itemId, amount: 1 }); + if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId }); + + await usable.use(ctx ?? this.context); + return true; + } + + /** Resolve an item reference, deriving `itemId` from slot contents if only `slotId` is given. */ + #resolveItem(arg?: string | { itemId?: string; slotId?: SlotId }): { itemId: string; slotId?: SlotId } | null { + if (!arg) return null; + if (typeof arg === 'string') return { itemId: arg }; + + if (arg.slotId !== undefined) { + const contents = this.getSlotContents(arg.slotId); + if (!contents) return null; + if (arg.itemId && contents.itemId !== arg.itemId) return null; + return { itemId: contents.itemId, slotId: arg.slotId }; } - const resolvedCtx = ctx ?? { self: this.entity, world: this.entity.world }; - await usable.use(resolvedCtx); - return true; + return arg.itemId ? { itemId: arg.itemId } : null; + } + + getSlotContents(slotId: SlotId): { itemId: string; amount: number } | null { + return this.#slot(slotId)?.contents ?? null; } getAmount(itemId: string, slotId?: SlotId): number {