GetContainerItemInfo() reports charged items (Wizard Oil, wands...) with a negative count, so ProcessMoveQueueBatched() treated the slot as empty and dropped the move. Use the item link to decide whether the source has an item, and move charged items as a stack of 1. This left one item in a bank bag being swapped, so the new bag couldn't be placed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019W3rwt5PZSpEDkXYzRuRhH
1016 lines
40 KiB
Lua
1016 lines
40 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 = {}
|
|
|
|
--- 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)
|
|
|
|
-- 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 (self.containers[bagNum].slotsFilled or 0) > 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 = self.containers[bagNum].slotsFilled <= 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
|
|
repeat
|
|
nextTargetSlotIndex = nextTargetSlotIndex + 1
|
|
nextTargetSlot = emptyBag_potentialAvailableSlots[nextTargetSlotIndex]
|
|
until not nextTargetSlot or nextTargetSlot.bagNum ~= bagNum
|
|
if not nextTargetSlot 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] = nextTargetSlot
|
|
end
|
|
end
|
|
end
|
|
-- Instructions for MoveItems().
|
|
self:QueueMove(item, nextTargetSlot)
|
|
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 this.
|
|
BsUtil.TableClear(emptyBag_potentialAvailableSlots)
|
|
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)
|
|
-- 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)
|
|
local filledSlots = (self.containers[bagNum].slotsFilled or 0)
|
|
|
|
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)
|