Files
Bagshui/Components/Inventory.Actions.lua
T
OEVGandClaude Opus 5.5 f9ff62e2b0 Use specialty bags as temporary space during bag swaps
AssignSpecialtySlots() sends items that fit a free specialty-bag slot
(C_Item.GetItemFamily() on the item and on the bag) there before general
space is counted; own specialty bags first, supplemental storage only
when general space runs short. Bags themselves are never assigned. Used
by EmptyBag() and GetAdditionalSlotsNeededToSwapBag(), so the bag
tooltip's "slots needed" agrees. FillBag() only refills a new specialty
bag with items that fit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019W3rwt5PZSpEDkXYzRuRhH
2026-11-02 05:25:17 +00:00

1154 lines
46 KiB
Lua

-- Bagshui Inventory Prototype: Actions
-- Moving, equipping, making stuff happen.
Bagshui:AddComponent(function()
local Inventory = Bagshui.prototypes.Inventory
--- Clear all stock change indicators.
function Inventory:ResetStockState()
for _, container in pairs(self.inventory) do
for _, item in pairs(container) do
item.bagshuiDate = 0
item.bagshuiStockState = BS_ITEM_STOCK_STATE.NO_CHANGE
end
end
-- Update the window, cascade to docked inventory, and force cache update.
-- (Inventory:UpdateCache() is where hasChanges is updated).
self:ForceUpdateWindow(true, true)
end
--- Return `true` if an item is protected from sale by the current settings.
---@param item table Bagshui inventory cache entry.
---@return string? protectionType Localized reason the item was protected ("Soulbound", "Quality", etc.), if any.
function Inventory:GetItemSellProtectionReason(item)
-- Protection is completely disabled.
if not self.settings.sellProtectionEnabled then
return
end
-- Active Quest.
if self.settings.sellProtectionActiveQuest and Bagshui.activeQuestItems[item.name] then
return L.ItemPropFriendly_activeQuest
end
-- Soulbound.
if self.settings.sellProtectionSoulbound and BsRules:Match("Soulbound()", item) then
return L.ItemPropFriendly_soulbound
end
-- Equipped Gear.
if self.settings.sellProtectionEquipped and BsCharacter.equippedHistory[item.itemString] then
return L.EquippedGear
end
-- Quality at or above threshold.
if (item.quality or 1) >= self.settings.sellProtectionQualityThreshold then
return L.Quality
end
end
--- Reset the pending sale of an item so it's no longer highlighted.
---@param itemButton table? If provided, call OnEnter for this item button to update the tooltip.
---@param noUpdate boolean? Don't call `Inventory:UpdateItemSlotColors()`.
function Inventory:ClearItemPendingSale(itemButton, noUpdate)
if self.itemPendingSale then
self.itemPendingSale = nil
self.highlightItemsInContainerId = nil
self.highlightItemsContainerSlot = nil
if itemButton then
self:ItemButton_OnEnter(itemButton)
end
if not noUpdate then
self:UpdateItemSlotColors()
end
end
end
--- Sort and organize inventory while the window is open.
--- Used from toolbars, menus, and key bindings.
function Inventory:Resort()
self.forceResort = true
-- Do NOT use QueueUpdate() here.
self:Update(true)
end
--- Initiate the restacking process.
--- This doesn't do the actual work; it just sets up a queue of restack operations,
--- which is necessary because there needs to be a slight delay between each operation.
--- That process is handled by ProcessMoveQueue().
function Inventory:Restack()
-- Ensure inventory cache is up to date.
self.cacheUpdateNeeded = true
self:UpdateCache()
-- This function can be called when a docked inventory has partial stacks
-- but this one doesn't. The case where this one has partials and the docked
-- one may or may not is handled once Inventory:ProcessMoveQueue() is done.
if not self.multiplePartialStacks then
if self.dockedInventory then
self.dockedInventory:Restack()
end
return
end
-- Prepare the helper function for sorting partial stacks.
if not self._PartialStackSortHelper then
self._PartialStackSortHelper = function(itmA, itmB)
return itmA.count > itmB.count
end
end
-- Reset restack queues.
self:ClearMoveQueue()
-- Reset restack retry count (used by ProcessMoveQueue() to decide when to give up if items are locked).
self.moveRetryCount = 0
-- Find all the partial stacks.
-- This will build out a table:
-- ```
-- {
-- [itemId1] = {
-- itemCacheReference1,
-- itemCacheReference2,
-- itemCacheReference3,
-- }
-- [itemId2] = {
-- itemCacheReference4,
-- itemCacheReference5,
-- }
-- }
-- ```
local allPartialStacks = {}
for _, bagContents in pairs(self.inventory) do
for _, item in ipairs(bagContents) do
if (self.partialStacks[item.id] or 0) > 1 and item.count < item.maxStackCount then
if allPartialStacks[item.id] == nil then
allPartialStacks[item.id] = {}
end
table.insert(allPartialStacks[item.id], item)
end
end
end
-- Sort partial stack lists for each item from largest to smallest.
for _, partialStacks in pairs(allPartialStacks) do
table.sort(partialStacks, self._PartialStackSortHelper)
end
local target, source, oldTargetCount, sourceLoopStart
-- Process each item's set of partial stacks in turn to create the restack queue.
for _, partialStacks in pairs(allPartialStacks) do
--self:PrintDebug("Restacking item ID " .. _)
-- Sources are obtained starting from the end of the list since it's
-- sorted largest to smallest and we're trying to move stacks with the
-- lowest counts onto those with the highest counts.
sourceLoopStart = table.getn(partialStacks)
-- Likewise, targets are obtained from the beginning of the list.
for targetIndex = 1, table.getn(partialStacks) - 1 do
target = partialStacks[targetIndex]
--self:PrintDebug("> starting target count: " .. target.count .. " (of max " .. target.maxStackCount .. " )")
if target.count < target.maxStackCount then
-- Work backwards through the list of partial stacks, potentially
-- up to the next-to-first (which is the initial target, so we
-- know we can never use it as a source).
for sourceIndex = sourceLoopStart, 2, -1 do
source = partialStacks[sourceIndex]
--self:PrintDebug(">> source count: " .. source.count)
if source ~= target and source.count > 0 and target.count < target.maxStackCount and target.count > 0 then
-- Calculate the changes.
oldTargetCount = target.count
target.count = math.min(target.count + source.count, target.maxStackCount)
source.count = source.count - (target.count - oldTargetCount)
-- Queue move of source stacks onto target (with the exact amount, for instant moves).
self:QueueMove(source, target, target.count - oldTargetCount)
--self:PrintDebug(string.format(">>> queued %s:%s (%s) to %s:%s (%s)", source.bagNum, source.slotNum, source.count, target.bagNum, target.slotNum, target.count))
--self:PrintDebug(">> NEW source count: " .. source.count)
--self:PrintDebug(">> NEW target count: " .. target.count)
-- We're done with this source and can move on to the next one.
if source.count == 0 then
sourceLoopStart = sourceLoopStart - 1
end
-- Stop the inner loop if current target is maxed out.
if target.count == target.maxStackCount then
--self:PrintDebug(">> target maxed; breaking inner loop")
break
end
end
end
end
end
end
--self:PrintDebug(table.getn(self.queuedMoveSources) .. " moves queued")
-- Reusable function for restacking docked Inventory class.
if self.dockedInventory and not self.restack_callback then
self.restack_callback = function()
self.dockedInventory:Restack()
end
end
-- Make the actual moves.
self:MoveItems(self.restack_callback)
end
function Inventory:ClearMoveQueue()
BsUtil.TableClear(self.queuedMoveSources)
BsUtil.TableClear(self.queuedMoveTargets)
BsUtil.TableClear(self.queuedMoveCounts)
end
---@param source table Inventory cache entry to move from.
---@param target table Inventory cache entry to move to.
---@param count number? How many to move (whole stack when omitted).
function Inventory:QueueMove(source, target, count)
table.insert(self.queuedMoveSources, source)
table.insert(self.queuedMoveTargets, target)
table.insert(self.queuedMoveCounts, count or 0)
end
--- Perform queued item move operations.
---
--- 🚦 Before calling, the `Inventory.queuedMoveSources/Targets` arrays must be
--- filled with the corresponding items that should be moved. (There aren't any
--- functions to do this; just use `table.insert()`).
---
--- `Inventory:Restack()` is a good example of how to do this.
---@param onComplete function? Will receive one boolean parameter indicating the success or failure of the move operations.
---@param onProgress function? Will receive one number parameter indicating the current item in the list of moves that just completed.
---@param resortAfterEachMove boolean? Organize the inventory after every action.
function Inventory:MoveItems(onComplete, onProgress, resortAfterEachMove)
-- Tracks the overall success of all operations in this session and is passed to `moveQueue_onComplete`.
self.moveQueue_success = true
-- Invoked when the move queue is empty and passed the success/failure status.
self.moveQueue_onComplete = onComplete
-- Used to calculate the current step for `moveQueue_onProgress()` since
self.moveQueue_totalItems = table.getn(self.queuedMoveSources)
-- Invoked after each move and passed the number of the move.
self.moveQueue_onProgress = onProgress
-- Organize inventory after every move.
self.moveQueue_resort = resortAfterEachMove
-- Reset tracking.
self.moveQueue_LastSource = nil
if self:CanBatchMoves() then
self:StartMoveQueueBatched()
else
self:ProcessMoveQueue()
end
end
--- Can the queued moves be done with ClassicAPI's C_Container.MoveItem() (no cursor, all at once)?
--- Equipping a bag (a numeric target) still needs the cursor.
---@return boolean
function Inventory:CanBatchMoves()
if type(_G.C_Container) ~= "table" or type(_G.C_Container.MoveItem) ~= "function" then
return false
end
for _, target in ipairs(self.queuedMoveTargets) do
if type(target) ~= "table" then
return false
end
end
return true
end
--- Copy the queued moves into the batch lists and start the first round.
function Inventory:StartMoveQueueBatched()
self.moveBatch_sources = self.moveBatch_sources or {}
self.moveBatch_targets = self.moveBatch_targets or {}
self.moveBatch_counts = self.moveBatch_counts or {}
self.moveBatch_usedSlots = self.moveBatch_usedSlots or {}
BsUtil.TableClear(self.moveBatch_sources)
BsUtil.TableClear(self.moveBatch_targets)
BsUtil.TableClear(self.moveBatch_counts)
-- Amounts are only valid when they line up with the queue (FillBag() doesn't set any).
local haveCounts = table.getn(self.queuedMoveCounts) == table.getn(self.queuedMoveSources)
for i = 1, table.getn(self.queuedMoveSources) do
table.insert(self.moveBatch_sources, self.queuedMoveSources[i])
table.insert(self.moveBatch_targets, self.queuedMoveTargets[i])
table.insert(self.moveBatch_counts, haveCounts and self.queuedMoveCounts[i] or 0)
end
self:ClearMoveQueue()
self.moveBatch_retries = 0
self.moveBatch_waiting = false
self.moveBatch_done = 0
self:ProcessMoveQueueBatched()
end
--- # 🛑 Do not call directly; use `Inventory:MoveItems()` 🛑.
--- Instant item mover using ClassicAPI's C_Container.MoveItem() (same approach as pfUI's bag sort).
--- Each round sends every move whose slots aren't already part of this round and aren't locked,
--- then waits for BAG_UPDATE_DELAYED (server confirmation) before the next round. A stack that
--- is split onto several targets, or a locked slot, simply waits for a later round.
function Inventory:ProcessMoveQueueBatched()
self.moveBatch_waiting = false
local sources = self.moveBatch_sources
local targets = self.moveBatch_targets
local counts = self.moveBatch_counts
-- Are we done?
if table.getn(sources) <= 0 then
self:QueueUpdate(0.1)
if type(self.moveQueue_onComplete) == "function" then
self.moveQueue_onComplete(self.moveQueue_success)
end
return
end
-- Set resorting for this inventory and any supplemental storage.
if self.moveQueue_resort then
self.forceResort = true
if type(self.bagSwappingSupplementalStorage) == "table" then
for _, inventoryClassName in ipairs(self.bagSwappingSupplementalStorage) do
if Bagshui.components[inventoryClassName] and Bagshui.components[inventoryClassName].online then
Bagshui.components[inventoryClassName].forceResort = true
end
end
end
end
local usedSlots = self.moveBatch_usedSlots
BsUtil.TableClear(usedSlots)
local fired = 0
local i = 1
while i <= table.getn(sources) do
local source = sources[i]
local target = targets[i]
local sourceKey = source.bagNum .. ":" .. source.slotNum
local targetKey = target.bagNum .. ":" .. target.slotNum
local handled = false
if not usedSlots[sourceKey] and not usedSlots[targetKey] then
local _, sourceCount, sourceLocked = _G.GetContainerItemInfo(source.bagNum, source.slotNum)
local _, _, targetLocked = _G.GetContainerItemInfo(target.bagNum, target.slotNum)
if not sourceLocked and not targetLocked then
-- Is there anything in the source slot? Check the link, not the count: items with
-- charges (Wizard Oil, wands...) report their count as minus the number of charges.
local sourceHasItem = _G.GetContainerItemLink(source.bagNum, source.slotNum) ~= nil
if not sourceCount or sourceCount < 1 then
sourceCount = 1
end
if sourceHasItem then
-- Amount to move: the queued amount, but never more than is actually there.
local count = counts[i]
if not count or count <= 0 or count > sourceCount then
count = sourceCount
end
-- Try to make stock states and dates make sense (same rules as ProcessMoveQueue()).
local oldSourceStockState = source.bagshuiStockState
local oldTargetStockState = target.bagshuiStockState
if
oldSourceStockState
and oldSourceStockState ~= BS_ITEM_STOCK_STATE.NO_CHANGE
and (
oldSourceStockState ~= BS_ITEM_STOCK_STATE.DOWN
or not oldTargetStockState
or oldTargetStockState == BS_ITEM_STOCK_STATE.NO_CHANGE
)
then
target.bagshuiStockState = oldSourceStockState
end
target.bagshuiDate = math.max(source.bagshuiDate or 0, target.bagshuiDate or 0)
_G.C_Container.MoveItem(source.bagNum, source.slotNum, target.bagNum, target.slotNum, count)
fired = fired + 1
end
-- Done with this move (an empty source means there's nothing left to move).
usedSlots[sourceKey] = true
usedSlots[targetKey] = true
handled = true
end
end
if handled then
table.remove(sources, i)
table.remove(targets, i)
table.remove(counts, i)
self.moveBatch_done = self.moveBatch_done + 1
if type(self.moveQueue_onProgress) == "function" then
self.moveQueue_onProgress(self.moveBatch_done)
end
else
i = i + 1
end
end
if fired > 0 then
self.moveBatch_retries = 0
-- Wait for the server to confirm (BAG_UPDATE_DELAYED, see Inventory:OnEvent()),
-- with a fallback in case that event never arrives.
self.moveBatch_waiting = true
Bagshui:QueueClassCallback(self, self.ProcessMoveQueueBatched, 1.0)
return
end
if table.getn(sources) <= 0 then
-- Everything left was already empty; finish up.
Bagshui:QueueClassCallback(self, self.ProcessMoveQueueBatched, 0.05)
return
end
-- Nothing could be moved this round (slots still locked). Retry a few times, then give up.
self.moveBatch_retries = self.moveBatch_retries + 1
if self.moveBatch_retries > 5 then
for j = 1, table.getn(sources) do
Bagshui:ShowAndLogErrorMessage(string.format(L.Error_RestackFailed, sources[j].name or "?"))
end
self.moveQueue_success = false
BsUtil.TableClear(sources)
BsUtil.TableClear(targets)
BsUtil.TableClear(counts)
end
Bagshui:QueueClassCallback(self, self.ProcessMoveQueueBatched, 0.5)
end
--- # 🛑 Do not call directly; use `Inventory:MoveItems()` 🛑.
--- Recursive item mover with delayed retry on fail.
--- Utilizes the class properties that are set by `Inventory:MoveItems()`.
function Inventory:ProcessMoveQueue()
--self:PrintDebug("Starting ProcessMoveQueue()")
-- Are we done?
if table.getn(self.queuedMoveSources) <= 0 then
--self:PrintDebug("Nothing left to do")
self:QueueUpdate(0.1)
-- Trigger callback.
if type(self.moveQueue_onComplete) == "function" then
self.moveQueue_onComplete(self.moveQueue_success)
end
return
end
--self:PrintDebug("starting move # " .. table.getn(self.queuedMoveSources))
-- Defaults.
local queueDelay = 0.15
local thisMoveSucceeded = false
-- Get info about the current operation.
local source = self.queuedMoveSources[1]
local target = self.queuedMoveTargets[1]
-- Reset retry count.
if source ~= self.moveQueue_LastSource then
self.moveRetryCount = 0
end
-- Make sure neither item is locked
-- ClassicAPI's C_Container returns nil for empty slots, otherwise a table with `isLocked`.
local sourceInfo = _G.C_Container.GetContainerItemInfo(source.bagNum, source.slotNum)
local targetInfo = _G.C_Container.GetContainerItemInfo(target.bagNum, target.slotNum)
local sourceLocked = sourceInfo and sourceInfo.isLocked
local targetLocked = targetInfo and targetInfo.isLocked
-- Only attempt the move if everything is unlocked
if not sourceLocked and not targetLocked then
-- Record stock states so we can revise them.
local oldSourceStockState = source.bagshuiStockState
local oldSourceDate = source.bagshuiDate or 0
local oldTargetStockState = target.bagshuiStockState
local oldTargetDate = target.bagshuiDate or 0
-- Set resorting for this inventory and any supplemental storage.
if self.moveQueue_resort then
self.forceResort = self.forceResort or self.moveQueue_resort
if
type(self.bagSwappingSupplementalStorage) == "table"
and table.getn(self.bagSwappingSupplementalStorage) > 0
then
for _, inventoryClassName in ipairs(self.bagSwappingSupplementalStorage) do
if Bagshui.components[inventoryClassName] and Bagshui.components[inventoryClassName].online then
Bagshui.components[inventoryClassName].forceResort = Bagshui.components[inventoryClassName].forceResort
or self.moveQueue_resort
end
end
end
end
-- Do the actual move.
self:MoveItem(source, target)
if not _G.CursorHasItem() then
-- Try to make stock states and dates make sense.
-- Always copy source stock state over target if the source was new/increased,
-- but only copy a decreased stock state if the target didn't have a state.
if
oldSourceStockState
and oldSourceStockState ~= BS_ITEM_STOCK_STATE.NO_CHANGE
and (
oldSourceStockState ~= BS_ITEM_STOCK_STATE.DOWN
or (
oldSourceStockState == BS_ITEM_STOCK_STATE.DOWN
and (not oldTargetStockState or oldTargetStockState == BS_ITEM_STOCK_STATE.NO_CHANGE)
)
)
then
target.bagshuiStockState = oldSourceStockState
end
target.bagshuiDate = math.max(oldSourceDate, oldTargetDate)
-- These can come out of the queue now.
thisMoveSucceeded = true
-- Reset retry count.
self.moveRetryCount = 0
end
end
-- Move attempt failed.
if not thisMoveSucceeded then
-- Show error message and retry if we haven't reached the limit.
local baseError = string.format(L.Error_RestackFailed, source.name)
if (self.moveRetryCount or 0) < 5 then
-- Longer delay until retry if either item was locked.
queueDelay = 0.5
if self.moveRetryCount > 1 then
Bagshui:ShowErrorMessage(string.format(L.Error_Suffix_Retrying, baseError), self.inventoryType, 1.0, 0.578, 0)
queueDelay = 1.0
end
self.moveRetryCount = self.moveRetryCount + 1
else
-- Once we've tried several times, give up on this operation and move to the next one.
Bagshui:ShowAndLogErrorMessage(baseError)
self.moveQueue_success = false
self.moveRetryCount = 0
thisMoveSucceeded = true
end
end
-- Once we're done with this set, take them out of the queue.
if thisMoveSucceeded then
table.remove(self.queuedMoveSources, 1)
table.remove(self.queuedMoveTargets, 1)
-- Update progress.
if type(self.moveQueue_onProgress) == "function" then
self.moveQueue_onProgress(self.moveQueue_totalItems - table.getn(self.queuedMoveSources))
end
end
-- Queue either the next restack operation or retry the current one.
-- The check at the beginning of this function will stop things when we're done.
--self:PrintDebug("Remaining: " .. table.getn(self.queuedMoveSources))
Bagshui:QueueClassCallback(self, self.ProcessMoveQueue, queueDelay, nil)
end
--- Move an item from one place to another, ensuring no modifier keys get in the way.
---@param source table Inventory cache entry.
---@param target table|number Inventory cache entry *or* bag number, if equipping a bag.
---@param onComplete function? Called once the operation is complete with a boolean parameter indicating whether the cursor still has an item.
function Inventory:MoveItem(source, target, onComplete)
local doCallback = true
-- Empty the cursor just in case, since we're technically picking up and putting down items.
_G.ClearCursor()
-- Intentionally calling the game's `PickupContainerItem()` instead of `Bagshui:PickupItem()`
-- because it's immediately picked up and put down, and we don't need (or want) to invoke
-- `ContainerFrameItemButton_OnClick()` either. As an extra safety measure, force all
-- modifier keys to return false for the duration of the move since some addons
-- hook `PickupContainerItem()` and change its behavior when a modifier is pressed.
local oldIsAltKeyDown = _G.IsAltKeyDown
local oldIsControlKeyDown = _G.IsControlKeyDown
local oldIsShiftKeyDown = _G.IsShiftKeyDown
_G.IsAltKeyDown = BsUtil.ReturnFalse
_G.IsControlKeyDown = BsUtil.ReturnFalse
_G.IsShiftKeyDown = BsUtil.ReturnFalse
_G.PickupContainerItem(source.bagNum, source.slotNum)
if type(target) == "table" then
_G.PickupContainerItem(target.bagNum, target.slotNum)
elseif type(target) == "number" then
-- This is a bag being put into a bag slot.
local inventorySlot = _G.ContainerIDToInventoryID(target)
if target > 4 then
-- Bank bag slot: the game's own bank bag buttons use PutItemInBag(); EquipCursorItem()
-- doesn't place bags there.
_G.PutItemInBag(inventorySlot)
else
_G.EquipCursorItem(inventorySlot)
-- Fallback in case equipping didn't take the bag off the cursor.
-- (PutItemInBag() does nothing when the cursor is empty.)
if _G.CursorHasItem() then
_G.PutItemInBag(inventorySlot)
end
end
-- Might be BOE, so pass callback responsibilities over.
-- (Could check for BOE but there's really no reason to do so when
-- WaitForStaticPopupClose() will just invoke the callback immediately
-- if the BOE dialog doesn't open.)
BsUtil.WaitForStaticPopupClose(
"EQUIP_BIND",
300, -- Five minute wait.
function(waitSuccess)
if type(onComplete) == "function" then
if waitSuccess == false then
onComplete(false)
else
onComplete((not _G.CursorHasItem()))
end
end
end
)
-- Callback is handled by WaitForStaticPopupClose().
doCallback = false
end
_G.IsAltKeyDown = oldIsAltKeyDown
_G.IsControlKeyDown = oldIsControlKeyDown
_G.IsShiftKeyDown = oldIsShiftKeyDown
if doCallback and type(onComplete) == "function" then
onComplete((not _G.CursorHasItem()))
end
end
-- When the bag being swapped in is inside the bag it's replacing, we need
-- to know where it gets moved to so we can actually equip it.
local swapBag_sourceItemTracker = {}
--- Vanilla doesn't allow non-empty bags to be swapped, and manually emptying and
--- refilling is a pain. Let's make that better.
--- ### Note about profession bags and swapping.
--- There doesn't seem to be a way to ask whether an item can go in a container
--- other than calling `PutItemInBag()` and seeing whether it errors. For this
--- reason, profession bags are being excluded as valid temporary storage during
--- bag swapping. (Profession bags themselves can still be swapped.)
---@param sourceItem table New bag (entry from inventory cache).
---@param bagNum number Bag slot to swap.
---@param phase number? Used internally by the swapping process to trigger each phase (empty / swap / fill).
function Inventory:SwapBag(sourceItem, bagNum, phase, force)
if type(sourceItem) ~= "table" or sourceItem.emptySlot == 1 or not BsItemInfo:IsContainer(sourceItem) then
-- If this is the initial call and the item isn't a bag, error out.
-- After that, jump straight to refilling the original bag.
if (phase or 1) == 1 then
Bagshui:ShowErrorMessage(_G.BAG_NOT_EQUIPPABLE, nil, nil, nil, nil, nil, nil, false, true)
return
else
phase = 3
end
end
if (phase or 1) == 1 then
-- PHASE 1: Empty the old bag.
-- Total steps is:
-- Number of items in the bag being emptied
-- + 1 (Equip the new bag)
-- + Number of items to put back in the new bag
self.swapBag_totalSteps = self.containers[bagNum].slotsFilled * 2 + 1
-- Phase 2 waits for the emptied bag to be confirmed (see below).
self.swapBag_emptyWaits = 0
-- Consumed by `Inventory:UpdateSwapBagProgress()`.
self.swapBag_currentStep = 0
-- In case the bag was inside the bag we're swapping, we'll need to know its
-- new location so we can actually equip it.
swapBag_sourceItemTracker[1] = sourceItem
self:EmptyBag(
bagNum,
swapBag_sourceItemTracker,
-- Completion callback.
function(success)
if success == false or type(success) == "string" then
Bagshui:ShowErrorMessage(type(success) == "string" and success or L.Error_BagSwap_EmptyOldFailed)
self:UpdateSwapBagProgress(bagNum)
-- Try to put things back.
self:SwapBag(sourceItem, bagNum, 3)
return
end
-- Need a slight delay here so the cache has time to catch up.
Bagshui:QueueClassCallback(self, self.SwapBag, 0.15, nil, swapBag_sourceItemTracker[1], bagNum, 2)
end,
-- Progress callback.
function()
self:UpdateSwapBagProgress(bagNum)
end
)
elseif phase == 2 then
-- PHASE 2: Equip the new bag.
-- The old bag has to be empty on the server before the new one can go in. Instant moves
-- (especially out of the bank) can take a moment to be confirmed, so wait up to ~3 seconds.
if not self:ContainerIsEmpty(bagNum) and (self.swapBag_emptyWaits or 0) < 12 then
self.swapBag_emptyWaits = (self.swapBag_emptyWaits or 0) + 1
Bagshui:QueueClassCallback(self, self.SwapBag, 0.25, nil, sourceItem, bagNum, 2)
return
end
self.swapBag_emptyWaits = 0
self:EquipBag(
sourceItem,
bagNum,
-- Completion callback.
function(success)
if success == false or type(success) == "string" then
Bagshui:ShowErrorMessage(type(success) == "string" and success or L.Error_BagSwap_EquipNewFailed)
end
self:UpdateSwapBagProgress(bagNum)
-- Always put things back even if the equip attempt failed.
self:SwapBag(sourceItem, bagNum, 3)
end
)
elseif phase == 3 then
-- PHASE 3: Fill the new bag.
Bagshui:QueueClassCallback(
self,
self.FillBag,
0.15,
nil,
bagNum,
self.tempMoveTargets,
-- Completion callback.
function()
BsUtil.TableClear(self.tempMoveTargets)
self:UpdateSwapBagProgress(bagNum, true)
self.forceResort = true
-- Need to use QueueUpdate() here to ensure forceResort is consumed properly
-- with the other window changes that are going to be happening at the same time.
self:QueueUpdate()
end,
-- Progress callback.
function()
self:UpdateSwapBagProgress(bagNum)
end
)
end
end
--- Does the game report the container as having no items in it?
---@param bagNum number Container to check.
---@return boolean
function Inventory:ContainerIsEmpty(bagNum)
for slotNum = 1, (_G.GetContainerNumSlots(bagNum) or 0) do
if _G.GetContainerItemLink(bagNum, slotNum) then
return false
end
end
return true
end
--- Increment the progress indicator to show how far along the bag swap is.
--- Always call with `done = true` at the end of the swap to ensure the progress indicator is hidden.
---@param bagNum number Bag slot being swapped.
---@param done boolean? Swap is complete.
function Inventory:UpdateSwapBagProgress(bagNum, done)
if done then
self.temporarilyShowBagBar = false
self.ui.buttons.bagSlots[self.myContainerIds[bagNum]].bagshuiData.progressPercent = 100
else
-- We're using the bag bar and cooldown to display progress.
if not self.temporarilyShowBagBar then
self.temporarilyShowBagBar = true
self:UpdateWindow()
end
self.swapBag_currentStep = self.swapBag_currentStep + 1
self.ui.buttons.bagSlots[self.myContainerIds[bagNum]].bagshuiData.progressPercent = self.swapBag_currentStep
/ self.swapBag_totalSteps
* 100
end
end
-- Used to determine whether there are enough free slots to empty a bag.
-- (Reusable table for `Inventory:EmptyBag()`.)
local emptyBag_potentialAvailableSlots = {}
-- Items from the bag being emptied that go to specialty bags: { [item] = target slot }.
-- (Reusable tables for `Inventory:EmptyBag()` and `Inventory:GetAdditionalSlotsNeededToSwapBag()`.)
local emptyBag_specialtyAssignments = {}
local slotsNeeded_specialtyAssignments = {}
local assignSpecialty_items = {}
local assignSpecialty_usedSlots = {}
--- Do the two bag family bitmasks share a bit? (No bitwise operators in Lua 5.0.)
---@param a number
---@param b number
---@return boolean
local function FamiliesOverlap(a, b)
local bitValue = 1
while bitValue <= a and bitValue <= b do
if math.mod(math.floor(a / bitValue), 2) == 1 and math.mod(math.floor(b / bitValue), 2) == 1 then
return true
end
bitValue = bitValue * 2
end
return false
end
--- ClassicAPI's C_Item.GetItemFamily() tells which specialty bags an item or a bag belongs to.
---@return boolean
local function SpecialtyBagsSupported()
return type(_G.C_Item) == "table" and type(_G.C_Item.GetItemFamily) == "function"
end
--- Bag family bitmask of the bag in a container slot (0 = general-purpose or no bag).
--- Specialty bags: quiver, ammo pouch, soul, herb, enchanting, engineering, and Turtle's
--- meat/fish/leather/mining bags.
---@param containerId number
---@return number
function Inventory:GetBagFamily(containerId)
if not SpecialtyBagsSupported() or containerId <= 0 then
return 0
end
local bagLink = _G.GetInventoryItemLink("player", _G.ContainerIDToInventoryID(containerId))
return bagLink and _G.C_Item.GetItemFamily(bagLink) or 0
end
--- Bag family bitmask of an item (0 = goes in general-purpose bags only).
---@param itemLinkOrId string|number?
---@return number
local function GetItemFamilyOf(itemLinkOrId)
if not itemLinkOrId or not SpecialtyBagsSupported() then
return 0
end
return _G.C_Item.GetItemFamily(itemLinkOrId) or 0
end
--- Choose free specialty-bag slots for the items in `bagNum` that fit them, so those items don't
--- need general-purpose space during a bag swap. Own specialty bags are used first; those of
--- supplemental storage (bank/bags) only when general-purpose space here runs short.
---@param bagNum number Container being emptied.
---@param assignments table Filled with `{ [item] = target slot }`.
---@return integer remaining Items that still need a general-purpose slot.
function Inventory:AssignSpecialtySlots(bagNum, assignments)
BsUtil.TableClear(assignments)
BsUtil.TableClear(assignSpecialty_items)
BsUtil.TableClear(assignSpecialty_usedSlots)
-- Items that have to come out, with their family.
for _, item in ipairs(self.inventory[bagNum] or {}) do
if item.emptySlot ~= 1 then
table.insert(assignSpecialty_items, item)
end
end
local remaining = table.getn(assignSpecialty_items)
if remaining == 0 or not SpecialtyBagsSupported() then
return remaining
end
local function assignFrom(inventoryClass)
for _, containerId in ipairs(inventoryClass.containerIds) do
if remaining <= 0 then
return
end
local bagFamily = (containerId ~= bagNum) and inventoryClass:GetBagFamily(containerId) or 0
if bagFamily > 0 and inventoryClass.inventory[containerId] then
for slotNum = 1, (_G.GetContainerNumSlots(containerId) or 0) do
local slot = inventoryClass.inventory[containerId][slotNum]
if slot and not assignSpecialty_usedSlots[slot] and not _G.GetContainerItemLink(containerId, slotNum) then
-- First item that hasn't been placed yet and fits this bag.
for _, item in ipairs(assignSpecialty_items) do
if
not assignments[item]
-- Bags report the family of what they hold, but can't go in a specialty bag.
and not BsItemInfo:IsContainer(item)
and FamiliesOverlap(GetItemFamilyOf(item.itemLink or item.id), bagFamily)
then
assignments[item] = slot
assignSpecialty_usedSlots[slot] = true
remaining = remaining - 1
break
end
end
end
end
end
end
end
assignFrom(self)
if remaining > self:GetAdjustedEmptySlotCount(bagNum) and type(self.bagSwappingSupplementalStorage) == "table" then
for _, inventoryClassName in ipairs(self.bagSwappingSupplementalStorage) do
local supplemental = Bagshui.components[inventoryClassName]
if supplemental and supplemental.online then
assignFrom(supplemental)
end
end
end
return remaining
end
--- Remove all items from a bag.
--- Automatically backs up the new locations to `Inventory.tempMoveTargets`.
---@param bagNum number Container to empty.
---@param itemsToTrack table? Array of cache items to find the future position of if they move.
---@param onComplete function? Completion callback (will be called from `Inventory:MoveItems()`).
---@param onProgress function? Per-move callback (will be called from `Inventory:MoveItems()`).
function Inventory:EmptyBag(bagNum, itemsToTrack, onComplete, onProgress)
if not self.containers[bagNum] then
return
end
-- We're going to make things simpler by just working with the list of all empty
-- slots and skipping the ones that belong to the bag being emptied in the
-- loop where we fill `queuedMoveSources/Targets`.
BsUtil.TableCopyFlat(self.emptyGenericContainerSlots, emptyBag_potentialAvailableSlots)
-- Items that fit a specialty bag (herbs, soul shards, arrows...) go to one first.
local itemsNeedingGeneralSlots = self:AssignSpecialtySlots(bagNum, emptyBag_specialtyAssignments)
-- Find out how much free space there is, excluding the bag being emptied.
local availableEmptySlotCount = self:GetAdjustedEmptySlotCount(bagNum)
-- Extra space is needed. See if we can find any.
if itemsNeedingGeneralSlots > availableEmptySlotCount then
availableEmptySlotCount = availableEmptySlotCount
+ self:GetSupplementalEmptySlotCount(emptyBag_potentialAvailableSlots)
end
-- Before going any further, make sure there's enough space.
-- This is a variable instead of being in the `if` just below because it
-- needs to be updated if we run out of slots in `emptyBag_potentialAvailableSlots`.
local readyToEmpty = itemsNeedingGeneralSlots <= availableEmptySlotCount
if readyToEmpty then
-- Reset queues used by `Inventory:ProcessMoveQueue()`
self:ClearMoveQueue()
-- We'll need to iterate through `emptyGenericContainerSlots` to find the
-- next one that doesn't belong to this bag.
local nextTargetSlotIndex = 0
local nextTargetSlot
-- Find a corresponding empty slot for every item in this bag.
for _, item in ipairs(self.inventory[bagNum]) do
if item.emptySlot ~= 1 then
local targetSlot = emptyBag_specialtyAssignments[item]
if not targetSlot then
repeat
nextTargetSlotIndex = nextTargetSlotIndex + 1
nextTargetSlot = emptyBag_potentialAvailableSlots[nextTargetSlotIndex]
until not nextTargetSlot or nextTargetSlot.bagNum ~= bagNum
targetSlot = nextTargetSlot
end
if not targetSlot then
-- This hopefully shouldn't be hit but just in case.
readyToEmpty = false
break
end
-- Update tracking.
if type(itemsToTrack) == "table" then
for i = 1, table.getn(itemsToTrack) do
if itemsToTrack[i] == item then
itemsToTrack[i] = targetSlot
end
end
end
-- Instructions for MoveItems().
self:QueueMove(item, targetSlot)
end
end
end
-- Are we good to go?
if readyToEmpty then
-- Make items available for refilling.
BsUtil.TableCopyFlat(self.queuedMoveTargets, self.tempMoveTargets)
-- Move! Those! Items!
self:MoveItems(onComplete, onProgress, true)
-- No need to hold onto these.
BsUtil.TableClear(emptyBag_potentialAvailableSlots)
BsUtil.TableClear(emptyBag_specialtyAssignments)
else
-- Failure state.
self:ClearMoveQueue()
local errorMessage = (self:GetBagSlotSwappableText(bagNum, true) or L.Error_BagSwap_EmptyOldInsufficientSpace)
if type(onComplete) == "function" then
onComplete(errorMessage)
else
Bagshui:ShowErrorMessage(errorMessage)
end
end
end
--- Move the given list of items into a different bag.
---@param bagNum number Bag into which the items should be placed.
---@param items table[] Array of inventory cache entries.
---@param onComplete function? Completion callback (will be called from `Inventory:MoveItems()`).
---@param onProgress function? Per-move callback (will be called from `Inventory:MoveItems()`).
function Inventory:FillBag(bagNum, items, onComplete, onProgress)
if not self.inventory[bagNum] then
return
end
BsUtil.TableCopyFlat(items, self.queuedMoveSources)
BsUtil.TableCopyFlat(self.inventory[bagNum], self.queuedMoveTargets)
-- The new bag is a specialty bag: only bring back the items that fit it.
local newBagFamily = self:GetBagFamily(bagNum)
if newBagFamily > 0 then
for i = table.getn(self.queuedMoveSources), 1, -1 do
local source = self.queuedMoveSources[i]
if not FamiliesOverlap(GetItemFamilyOf(_G.GetContainerItemLink(source.bagNum, source.slotNum)), newBagFamily) then
table.remove(self.queuedMoveSources, i)
end
end
end
-- Whole stacks (no per-move amounts).
BsUtil.TableClear(self.queuedMoveCounts)
-- Prune source table if there aren't enough slots in the destination.
if table.getn(self.queuedMoveSources) > table.getn(self.queuedMoveTargets) then
for i = table.getn(self.queuedMoveSources), table.getn(self.queuedMoveTargets) + 1, -1 do
table.remove(self.queuedMoveSources, i)
end
end
self:MoveItems(onComplete, onProgress, true)
end
--- Equip a bag.
---@param item table Inventory cache entry of the bag to be equipped.
---@param bagNum number Bag slot into which the bag will be placed.
---@param onComplete function? Success/failure callback.
function Inventory:EquipBag(item, bagNum, onComplete)
self:MoveItem(item, bagNum, onComplete)
end
--- Find the count of free bag slots, less those belonging to `excludeBagNum` and
--- any profession bags.
---@param excludeBagNum number? Bag to omit.
---@return integer emptySlots
function Inventory:GetAdjustedEmptySlotCount(excludeBagNum)
local availableEmptySlotCount = 0
for containerNum, container in pairs(self.containers) do
if
containerNum ~= excludeBagNum
and container.numSlots > 0
and container.slotsFilled < container.numSlots
-- Generic bags only (no profession bags). It's too hard to know what
-- is allowed in a container without trying it, so we're just going
-- to ignore profession bags as swap targets.
and container.genericType == BsGameInfo.itemSubclasses["Container"]["Bag"]
then
availableEmptySlotCount = availableEmptySlotCount + (container.numSlots - container.slotsFilled)
end
end
return availableEmptySlotCount
end
-- Reusable table for `Inventory:GetSupplementalEmptySlotCount()`.
local getSupplementalEmptySlotCount_inventoriesChecked = {}
--- Determine how many slots are available in other inventories to help with bag swapping.
---@param availableSlotTable table? Array to which additional slots should be added.
---@return integer availableEmptySlotCount
---@return table<string,true> inventoriesChecked Other inventory classes that were checked for available slots.
function Inventory:GetSupplementalEmptySlotCount(availableSlotTable)
local availableEmptySlotCount = 0
BsUtil.TableClear(getSupplementalEmptySlotCount_inventoriesChecked)
if
-- Other inventory classes that can be used during swapping are configured in this property.
type(self.bagSwappingSupplementalStorage) == "table" and table.getn(self.bagSwappingSupplementalStorage) > 0
then
for _, inventoryClassName in ipairs(self.bagSwappingSupplementalStorage) do
local supplemental = Bagshui.components[inventoryClassName]
if supplemental and supplemental.online then
if table.getn(supplemental.emptyGenericContainerSlots) > 0 then
availableEmptySlotCount = availableEmptySlotCount + table.getn(supplemental.emptyGenericContainerSlots)
if availableSlotTable then
for _, slot in ipairs(supplemental.emptyGenericContainerSlots) do
table.insert(emptyBag_potentialAvailableSlots, slot)
end
end
end
getSupplementalEmptySlotCount_inventoriesChecked[inventoryClassName] = true
end
end
end
return availableEmptySlotCount, getSupplementalEmptySlotCount_inventoriesChecked
end
--- Calculate how many more slots are required before a swap of the specified bag can be performed.
---@param bagNum number Container to check.
---@return integer slotsNeeded
---@return table<string, true>? inventoriesChecked Second return value from `Inventory:GetSupplementalEmptySlotCount()`.
function Inventory:GetAdditionalSlotsNeededToSwapBag(bagNum)
-- Items that fit a free specialty-bag slot don't need general-purpose space.
local filledSlots = self:AssignSpecialtySlots(bagNum, slotsNeeded_specialtyAssignments)
BsUtil.TableClear(slotsNeeded_specialtyAssignments)
if filledSlots == 0 then
return 0
end
local availableEmptySlots = self:GetAdjustedEmptySlotCount(bagNum)
if availableEmptySlots >= filledSlots then
return 0
end
local supplementalEmptySlots, inventoriesChecked = self:GetSupplementalEmptySlotCount()
if availableEmptySlots + supplementalEmptySlots >= filledSlots then
return 0, inventoriesChecked
end
return (filledSlots - availableEmptySlots - supplementalEmptySlots), inventoriesChecked
end
--- Error-triggered bag swapping (as opposed to native bag swapping via a Bagshui
--- bag slot button's OnClick). This allows Bagshui to perform bag swaps even
--- if the equip action is invoked via the main action bar or any other method.
---
--- Methodology based on Swapper by Gabriele Cimolino
--- https://github.com/cubenicke/Swapper
---@param wowApiFunctionName any
---@param event any
---@param message any
function Inventory:UIErrorsFrame_OnEvent(wowApiFunctionName, event, message)
if event == "UI_ERROR_MESSAGE" and message == _G.TEXT(_G.ERR_DESTROY_NONEMPTY_BAG) then
local newBag, bagSlot
-- Find the bag that needs to be equipped.
for _, bagContents in pairs(self.inventory) do
for __, item in ipairs(bagContents) do
if item.locked == 1 then
newBag = item
end
end
end
-- Find the slot into which it should be put.
for _, containerId in ipairs(self.containerIds) do
if _G.IsInventoryItemLocked(_G.ContainerIDToInventoryID(containerId)) then
bagSlot = containerId
end
end
-- Suppress the error and do the swap.
if newBag and bagSlot then
self:SwapBag(newBag, bagSlot)
-- Can't just return here or it seems to break things, so
-- we'll blank the message instead.
message = ""
end
end
self.hooks:OriginalHook(wowApiFunctionName, event, message)
end
end)