diff --git a/src/common/rpg/inventory.ts b/src/common/rpg/inventory.ts new file mode 100644 index 0000000..9e1f1a5 --- /dev/null +++ b/src/common/rpg/inventory.ts @@ -0,0 +1,139 @@ +import type { InventoryOptions, InventorySlotInput } from "./types"; + +interface SlotEntry { + readonly slotId: string; + readonly limit: number | undefined; + state: { itemId: string; amount: number } | null; +} + +export class Inventory { + private readonly slots: Map; + private readonly maxAmountPerItem: Record; + + constructor(slotDefs: Array, options?: InventoryOptions) { + this.slots = new Map( + slotDefs.map(def => { + const slotId = typeof def === 'string' ? def : def.slotId; + const limit = typeof def === 'string' ? undefined : def.limit; + return [slotId, { slotId, limit, state: null }]; + }) + ); + this.maxAmountPerItem = options?.maxAmountPerItem ?? {}; + } + + // Max amount of itemId allowed in a single slot (min of slot.limit and maxAmountPerItem cap) + private slotCapFor(slot: SlotEntry, itemId: string): number { + const limitCap = slot.limit ?? Infinity; + const itemCap = this.maxAmountPerItem[itemId] ?? Infinity; + return Math.min(limitCap, itemCap); + } + + // Remaining space in slot for itemId (min of slotCap and remaining amount) + private slotRoomFor(slot: SlotEntry, itemId: string): number { + if (slot.state !== null && slot.state.itemId !== itemId) return 0; + return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0); + } + + addItem(itemId: string, amount: number, slotId?: string): boolean { + if (amount < 0) return false; + if (amount === 0) return true; + + if (slotId !== undefined) { + const slot = this.slots.get(slotId); + if (!slot) return false; + if (this.slotRoomFor(slot, itemId) < amount) return false; + slot.state = { itemId, amount: (slot.state?.amount ?? 0) + amount }; + return true; + } + + // Two-phase: pre-check then apply + let remaining = amount; + for (const slot of this.slots.values()) { + if (slot.state === null || slot.state.itemId === itemId) { + remaining -= Math.min(this.slotRoomFor(slot, itemId), remaining); + if (remaining === 0) break; + } + } + if (remaining > 0) return false; + + // Apply + remaining = amount; + for (const slot of this.slots.values()) { + if (slot.state?.itemId === itemId) { + const take = Math.min(this.slotRoomFor(slot, itemId), remaining); + slot.state.amount += take; + remaining -= take; + if (remaining === 0) return true; + } + } + for (const slot of this.slots.values()) { + if (slot.state === null) { + const take = Math.min(this.slotCapFor(slot, itemId), remaining); + slot.state = { itemId, amount: take }; + remaining -= take; + if (remaining === 0) return true; + } + } + + return remaining === 0; + } + + removeItem(itemId: string, amount: number, slotId?: string): boolean { + if (amount < 0) return false; + if (amount === 0) return true; + + if (slotId !== undefined) { + const slot = this.slots.get(slotId); + if (!slot || slot.state?.itemId !== itemId) return false; + if (slot.state.amount < amount) return false; + slot.state.amount -= amount; + if (slot.state.amount === 0) slot.state = null; + return true; + } + + if (this.getAmount(itemId) < amount) return false; + + let remaining = amount; + for (const slot of this.slots.values()) { + if (slot.state?.itemId === itemId) { + const take = Math.min(slot.state.amount, remaining); + slot.state.amount -= take; + if (slot.state.amount === 0) slot.state = null; + remaining -= take; + if (remaining === 0) return true; + } + } + + return remaining === 0; + } + + getAmount(itemId: string, slotId?: string): number { + if (slotId !== undefined) { + const slot = this.slots.get(slotId); + return slot?.state?.itemId === itemId ? slot.state.amount : 0; + } + let total = 0; + for (const slot of this.slots.values()) { + if (slot.state?.itemId === itemId) total += slot.state.amount; + } + return total; + } + + getItems(): Map { + const result = new Map(); + for (const slot of this.slots.values()) { + if (slot.state) { + result.set(slot.state.itemId, (result.get(slot.state.itemId) ?? 0) + slot.state.amount); + } + } + return result; + } + + getVariables(): Record { + const result: Record = {}; + for (const [itemId, amount] of this.getItems()) { + result[`inventory.${itemId}`] = amount; + } + return result; + } +} diff --git a/src/common/rpg/types.ts b/src/common/rpg/types.ts index 83635ac..b120b40 100644 --- a/src/common/rpg/types.ts +++ b/src/common/rpg/types.ts @@ -97,6 +97,17 @@ function isQuestStage(v: unknown): v is QuestStage { && Array.isArray(s.actions) && s.actions.every(isRPGAction); } +export interface InventorySlotDefinition { + slotId: string; + limit?: number; +} + +export type InventorySlotInput = string | InventorySlotDefinition; + +export interface InventoryOptions { + maxAmountPerItem?: Record; +} + export function isQuest(v: unknown): v is Quest { if (typeof v !== 'object' || v === null) return false; const q = v as Record;