diff --git a/Guda.toc b/Guda.toc index 6191939..93296cc 100644 --- a/Guda.toc +++ b/Guda.toc @@ -28,6 +28,7 @@ Data\EquipmentScanner.lua Data\EquipmentSets.lua Sorting\SortEngine.lua +Sorting\BagReplacer.lua # UI Lua must load before XML so OnLoad handlers exist UI\ItemButton.lua diff --git a/Sorting/BagReplacer.lua b/Sorting/BagReplacer.lua new file mode 100644 index 0000000..6604759 --- /dev/null +++ b/Sorting/BagReplacer.lua @@ -0,0 +1,476 @@ +--=========================================================================== +-- BagReplacer: Auto-replace occupied bags by evacuating items first +--=========================================================================== +local addon = Guda +local BagReplacer = {} +addon.Modules.BagReplacer = BagReplacer + +BagReplacer.inProgress = false + +local C = addon.Constants + +--=========================================================================== +-- Lock-wait mechanism (independent from SortEngine) +--=========================================================================== +local replacerFrame = CreateFrame("Frame") +local pendingSlots = {} -- flat array: {bagID1, slot1, bagID2, slot2, ...} +local pendingCount = 0 +local pendingSet = {} -- bagID*1000+slot -> true +local waitingForLocks = false +local onLocksCleared = nil + +local function AddPendingLock(bagID, slot) + local key = bagID * 1000 + slot + if not pendingSet[key] then + pendingSet[key] = true + pendingCount = pendingCount + 1 + pendingSlots[pendingCount * 2 - 1] = bagID + pendingSlots[pendingCount * 2] = slot + end +end + +local function ClearPendingLocks() + for k in pairs(pendingSet) do pendingSet[k] = nil end + for i = 1, pendingCount * 2 do pendingSlots[i] = nil end + pendingCount = 0 +end + +local function AnyPendingLocked() + if pendingCount == 0 then return false end + for i = 1, pendingCount do + local bagID = pendingSlots[i * 2 - 1] + local slot = pendingSlots[i * 2] + if bagID and slot then + local _, _, locked = GetContainerItemInfo(bagID, slot) + if locked then return true end + end + end + return false +end + +replacerFrame:RegisterEvent("ITEM_LOCK_CHANGED") +replacerFrame:SetScript("OnEvent", function() + if waitingForLocks and onLocksCleared then + if not AnyPendingLocked() then + waitingForLocks = false + local cb = onLocksCleared + onLocksCleared = nil + ClearPendingLocks() + cb() + end + end +end) + +local function WaitForLocksCleared(callback, timeout) + local minDelay = 0.5 + Guda_ScheduleTimer(minDelay, function() + if not AnyPendingLocked() then + ClearPendingLocks() + callback() + return + end + waitingForLocks = true + onLocksCleared = callback + local remaining = (timeout or 2.0) - minDelay + if remaining < 0.5 then remaining = 0.5 end + Guda_ScheduleTimer(remaining, function() + if waitingForLocks then + waitingForLocks = false + onLocksCleared = nil + ClearPendingLocks() + ClearCursor() + callback() + end + end) + end) +end + +--=========================================================================== +-- Check if item can go in a specific bag (mirrors SortEngine logic) +--=========================================================================== +local function CanItemGoInBag(itemLink, targetBagID) + if targetBagID == 0 or targetBagID == -1 or targetBagID == -2 then + return true + end + local bagType = addon.Modules.Utils:GetSpecializedBagType(targetBagID) + if not bagType then + return true + end + local preferredType = addon.Modules.Utils:GetItemPreferredContainer(itemLink) + return preferredType == bagType +end + +--=========================================================================== +-- Build list of free slots across all bags except the target bag +-- Returns: { {bagID, slotID, bagType}, ... } +--=========================================================================== +local function GetAvailableFreeSlots(excludeBagID) + local freeSlots = {} + local count = 0 + + -- Iterate bags 0-4, skipping the excluded bag + for bagID = 0, C.BAG_LAST do + if bagID ~= excludeBagID then + local numSlots = GetContainerNumSlots(bagID) + if numSlots and numSlots > 0 then + local bagType = addon.Modules.Utils:GetSpecializedBagType(bagID) + for slot = 1, numSlots do + local texture = GetContainerItemInfo(bagID, slot) + if not texture then + count = count + 1 + freeSlots[count] = { bagID = bagID, slot = slot, bagType = bagType } + end + end + end + end + end + + return freeSlots, count +end + +--=========================================================================== +-- Get items in a bag +-- Returns: { {slot, itemLink}, ... }, count +--=========================================================================== +local function GetBagItems(bagID) + local items = {} + local count = 0 + local numSlots = GetContainerNumSlots(bagID) + + if not numSlots or numSlots == 0 then + return items, 0 + end + + for slot = 1, numSlots do + local texture = GetContainerItemInfo(bagID, slot) + if texture then + local link = GetContainerItemLink(bagID, slot) + count = count + 1 + items[count] = { slot = slot, itemLink = link } + end + end + + return items, count +end + +--=========================================================================== +-- BuildEvacuationPlan: assign each item to a free slot, reserve stash slot +-- Returns: plan table or nil, errorMsg +--=========================================================================== +function BagReplacer:BuildEvacuationPlan(targetBagID) + local items, itemCount = GetBagItems(targetBagID) + addon:DebugSort("[BagReplacer] BuildEvacuationPlan: bag %d has %d items", targetBagID, itemCount) + if itemCount == 0 then + return { moves = {}, stashBag = nil, stashSlot = nil, itemCount = 0 }, nil + end + + local freeSlots, freeCount = GetAvailableFreeSlots(targetBagID) + local needed = itemCount + 1 -- +1 for stashing the new bag from cursor + addon:DebugSort("[BagReplacer] Need %d free slots (items+stash), have %d", needed, freeCount) + + if freeCount < needed then + return nil, string.format( + "Not enough free bag space. Need %d free slots, have %d.", + needed, freeCount + ) + end + + -- Separate free slots into specialized and regular + local regularFree = {} + local regularCount = 0 + local specializedFree = {} -- keyed by bagType + for i = 1, freeCount do + local fs = freeSlots[i] + if fs.bagType then + if not specializedFree[fs.bagType] then + specializedFree[fs.bagType] = {} + end + local t = specializedFree[fs.bagType] + t[table.getn(t) + 1] = fs + else + regularCount = regularCount + 1 + regularFree[regularCount] = fs + end + end + + local moves = {} + local moveCount = 0 + local regularIdx = 1 -- next regular free slot to use + + -- Assign each item to a free slot + for i = 1, itemCount do + local item = items[i] + local assigned = false + + -- Try specialized bag first if item has a preferred type + if item.itemLink then + local preferredType = addon.Modules.Utils:GetItemPreferredContainer(item.itemLink) + if preferredType and specializedFree[preferredType] then + local specSlots = specializedFree[preferredType] + if table.getn(specSlots) > 0 then + local fs = specSlots[table.getn(specSlots)] + specSlots[table.getn(specSlots)] = nil + moveCount = moveCount + 1 + moves[moveCount] = { + fromBag = targetBagID, fromSlot = item.slot, + toBag = fs.bagID, toSlot = fs.slot + } + addon:DebugSort("[BagReplacer] Plan move #%d: bag%d/slot%d -> bag%d/slot%d (specialized %s)", moveCount, targetBagID, item.slot, fs.bagID, fs.slot, preferredType) + assigned = true + end + end + end + + -- Fall back to regular free slot + if not assigned then + if regularIdx <= regularCount then + local fs = regularFree[regularIdx] + regularIdx = regularIdx + 1 + moveCount = moveCount + 1 + moves[moveCount] = { + fromBag = targetBagID, fromSlot = item.slot, + toBag = fs.bagID, toSlot = fs.slot + } + addon:DebugSort("[BagReplacer] Plan move #%d: bag%d/slot%d -> bag%d/slot%d (regular)", moveCount, targetBagID, item.slot, fs.bagID, fs.slot) + assigned = true + end + end + + if not assigned then + addon:DebugSort("[BagReplacer] Failed to assign slot %d to a free slot", item.slot) + return nil, string.format( + "Not enough compatible bag space. Need %d free slots, have %d.", + needed, freeCount + ) + end + end + + -- Reserve a regular free slot for stashing the new bag from cursor + local stashBag, stashSlot + if regularIdx <= regularCount then + local fs = regularFree[regularIdx] + stashBag = fs.bagID + stashSlot = fs.slot + addon:DebugSort("[BagReplacer] Stash slot: bag%d/slot%d", stashBag, stashSlot) + else + -- No regular free slot left for stash - check if any specialized slot works + -- (new bag is a container item, needs a regular slot) + return nil, string.format( + "Not enough free bag space. Need %d free slots, have %d.", + needed, freeCount + ) + end + + return { + moves = moves, + stashBag = stashBag, + stashSlot = stashSlot, + itemCount = itemCount + }, nil +end + +--=========================================================================== +-- Abort: cleanup on failure +--=========================================================================== +function BagReplacer:Abort(reason) + addon:DebugSort("[BagReplacer] Abort: %s", reason or "unknown") + ClearCursor() + ClearPendingLocks() + waitingForLocks = false + onLocksCleared = nil + self.inProgress = false + if reason then + addon:Print(reason) + end + -- Refresh UI + if addon.Modules.BagFrame and addon.Modules.BagFrame.Update then + addon.Modules.BagFrame:Update() + end +end + +--=========================================================================== +-- FinishReplacement: pick up new bag from stash, equip it, handle old bag +--=========================================================================== +function BagReplacer:FinishReplacement(invSlot, stashBag, stashSlot) + addon:DebugSort("[BagReplacer] FinishReplacement: picking up new bag from bag%d/slot%d, equipping to invSlot %d", stashBag, stashSlot, invSlot) + -- Verify new bag is still in stash slot + local stashTexture = GetContainerItemInfo(stashBag, stashSlot) + if not stashTexture then + self:Abort("Bag replacement failed: stashed bag is missing.") + return + end + + -- Pick up new bag from stash + PickupContainerItem(stashBag, stashSlot) + AddPendingLock(stashBag, stashSlot) + + -- Wait briefly for pickup, then equip + Guda_ScheduleTimer(0.3, function() + if CursorHasItem and CursorHasItem() then + addon:DebugSort("[BagReplacer] Equipping new bag to invSlot %d", invSlot) + EquipCursorItem(invSlot) + + -- Wait for equip to process, then try to place old bag + Guda_ScheduleTimer(0.5, function() + if CursorHasItem and CursorHasItem() then + addon:DebugSort("[BagReplacer] Old bag on cursor, finding free slot to place it") + -- Old bag is on cursor - try to find a free slot for it + local freeSlots, freeCount = GetAvailableFreeSlots(-999) -- exclude nothing + for i = 1, freeCount do + local fs = freeSlots[i] + if not fs.bagType then -- regular slot only + addon:DebugSort("[BagReplacer] Placing old bag into bag%d/slot%d", fs.bagID, fs.slot) + PickupContainerItem(fs.bagID, fs.slot) + ClearCursor() + break + end + end + -- If still on cursor, that's fine - user can place manually + end + + ClearPendingLocks() + waitingForLocks = false + onLocksCleared = nil + BagReplacer.inProgress = false + addon:Print("Bag replaced successfully!") + + -- Refresh UI + if addon.Modules.BagFrame and addon.Modules.BagFrame.Update then + addon.Modules.BagFrame:Update() + end + -- Invalidate bag scanner cache + if addon.Modules.BagScanner and addon.Modules.BagScanner.InvalidateCache then + addon.Modules.BagScanner:InvalidateCache() + end + end) + else + BagReplacer:Abort("Bag replacement failed: could not pick up new bag.") + end + end) +end + +--=========================================================================== +-- ExecuteNextMove: move one item, wait for lock, recurse +--=========================================================================== +function BagReplacer:ExecuteNextMove(plan, index, invSlot) + local moves = plan.moves + local totalMoves = table.getn(moves) + + -- All moves done - finish replacement + if index > totalMoves then + addon:DebugSort("[BagReplacer] All %d moves complete, finishing replacement", totalMoves) + self:FinishReplacement(invSlot, plan.stashBag, plan.stashSlot) + return + end + + local move = moves[index] + addon:DebugSort("[BagReplacer] ExecuteNextMove %d/%d: bag%d/slot%d -> bag%d/slot%d", index, totalMoves, move.fromBag, move.fromSlot, move.toBag, move.toSlot) + + -- Verify source slot still has an item (player may have moved it) + local sourceTexture = GetContainerItemInfo(move.fromBag, move.fromSlot) + if not sourceTexture then + addon:DebugSort("[BagReplacer] Source slot empty, skipping move %d", index) + -- Item already gone, skip to next + self:ExecuteNextMove(plan, index + 1, invSlot) + return + end + + -- Check if source is locked + local _, _, locked = GetContainerItemInfo(move.fromBag, move.fromSlot) + if locked then + addon:DebugSort("[BagReplacer] Source slot locked, waiting for unlock") + -- Wait for it to unlock, then retry this move + AddPendingLock(move.fromBag, move.fromSlot) + WaitForLocksCleared(function() + BagReplacer:ExecuteNextMove(plan, index, invSlot) + end) + return + end + + -- Execute the move + PickupContainerItem(move.fromBag, move.fromSlot) + PickupContainerItem(move.toBag, move.toSlot) + ClearCursor() + + -- Track locks + AddPendingLock(move.fromBag, move.fromSlot) + AddPendingLock(move.toBag, move.toSlot) + + -- Wait for locks to clear, then next move + WaitForLocksCleared(function() + BagReplacer:ExecuteNextMove(plan, index + 1, invSlot) + end) +end + +--=========================================================================== +-- Execute: main entry point +--=========================================================================== +function BagReplacer:Execute(targetBagID, invSlot) + addon:DebugSort("[BagReplacer] Execute: targetBagID=%d, invSlot=%d", targetBagID, invSlot) + + -- Guard: already in progress + if self.inProgress then + addon:DebugSort("[BagReplacer] Blocked: replacement already in progress") + addon:Print("Cannot replace bag: another replacement is in progress.") + return + end + + -- Guard: sorting in progress + local SortEngine = addon.Modules.SortEngine + if SortEngine and SortEngine.sortingInProgress then + addon:DebugSort("[BagReplacer] Blocked: sorting in progress") + addon:Print("Cannot replace bag while sorting is in progress.") + return + end + + -- Guard: combat + if UnitAffectingCombat("player") then + addon:DebugSort("[BagReplacer] Blocked: in combat") + addon:Print("Cannot replace bag during combat.") + return + end + + -- Check if bag is empty (normal equip works) + local _, itemCount = GetBagItems(targetBagID) + if itemCount == 0 then + addon:DebugSort("[BagReplacer] Bag is empty, using normal equip") + if EquipCursorItem then + EquipCursorItem(invSlot) + elseif PutItemInBag then + PutItemInBag(invSlot) + end + return + end + + -- Build evacuation plan + local plan, errorMsg = self:BuildEvacuationPlan(targetBagID) + if not plan then + addon:DebugSort("[BagReplacer] Plan failed: %s", errorMsg) + addon:Print(errorMsg) + return -- cursor untouched, user keeps holding new bag + end + + -- Start replacement + self.inProgress = true + addon:Print(string.format("Replacing bag: moving %d items...", plan.itemCount)) + addon:DebugSort("[BagReplacer] Starting replacement: %d moves, stash at bag%d/slot%d", table.getn(plan.moves), plan.stashBag, plan.stashSlot) + + -- Step 1: Stash the new bag from cursor into the reserved free slot + addon:DebugSort("[BagReplacer] Stashing cursor item to bag%d/slot%d", plan.stashBag, plan.stashSlot) + PickupContainerItem(plan.stashBag, plan.stashSlot) + -- This places cursor item (new bag) into stashSlot + AddPendingLock(plan.stashBag, plan.stashSlot) + + -- Wait for stash to complete, then start evacuating + WaitForLocksCleared(function() + -- Verify the new bag was stashed + local stashTexture = GetContainerItemInfo(plan.stashBag, plan.stashSlot) + if not stashTexture then + BagReplacer:Abort("Bag replacement failed: could not stash new bag.") + return + end + addon:DebugSort("[BagReplacer] Stash confirmed, beginning evacuation") + -- Begin moving items out of target bag + BagReplacer:ExecuteNextMove(plan, 1, invSlot) + end) +end diff --git a/UI/BagFrame.lua b/UI/BagFrame.lua index 80be837..7688500 100644 --- a/UI/BagFrame.lua +++ b/UI/BagFrame.lua @@ -3,6 +3,9 @@ local addon = Guda +-- Forward declaration (defined later near bag slot handlers) +local Guda_TryEquipBagOnSlot + local BagFrame = {} addon.Modules.BagFrame = BagFrame @@ -555,13 +558,7 @@ local function CreateBagFlyout(parent) btn:SetScript("OnReceiveDrag", function() if this and this.bagID and this.bagID ~= 0 and CursorHasItem and CursorHasItem() then local inv = ContainerIDToInventoryID(this.bagID) - if EquipCursorItem then - EquipCursorItem(inv) - elseif PutItemInBag then - PutItemInBag(inv) - end - Guda_BagSlot_Update(this, this.bagID) - if BagFrame and BagFrame.Update then BagFrame:Update() end + Guda_TryEquipBagOnSlot(this.bagID, inv, this) end end) @@ -2641,12 +2638,18 @@ local function HookBagContainers() local button = getglobal(buttonName) if button then + local bagID = i local originalOnClick = button:GetScript("OnClick") button:SetScript("OnClick", function() local mouseButton = arg1 or "LeftButton" -- Vanilla uses global arg1 if mouseButton == "LeftButton" then - -- Open Guda Bag View instead of default bag - BagFrame:Toggle() + -- If cursor has an item, try bag replacement instead of toggle + if CursorHasItem and CursorHasItem() then + local invSlot = ContainerIDToInventoryID(bagID) + Guda_TryEquipBagOnSlot(bagID, invSlot, nil) + else + BagFrame:Toggle() + end else -- Allow right-click and other buttons to work normally if originalOnClick then @@ -2654,6 +2657,14 @@ local function HookBagContainers() end end end) + + -- Hook OnReceiveDrag for drag-drop bag replacement + button:SetScript("OnReceiveDrag", function() + if CursorHasItem and CursorHasItem() then + local invSlot = ContainerIDToInventoryID(bagID) + Guda_TryEquipBagOnSlot(bagID, invSlot, nil) + end + end) end end @@ -3043,6 +3054,34 @@ function Guda_BagSlot_ApplyBackdrop(button) button:SetBackdropBorderColor(fbBorder[1], fbBorder[2], fbBorder[3], fbBorder[4]) end +-- Try to equip a bag from cursor onto a bag slot, auto-replacing if occupied +Guda_TryEquipBagOnSlot = function(bagID, invSlot, button) + if not CursorHasItem or not CursorHasItem() then return end + + -- Check if current bag has items + local numSlots = GetContainerNumSlots(bagID) + local hasItems = false + if numSlots and numSlots > 0 then + for slot = 1, numSlots do + local texture = GetContainerItemInfo(bagID, slot) + if texture then hasItems = true; break end + end + end + + if hasItems and addon.Modules.BagReplacer then + addon.Modules.BagReplacer:Execute(bagID, invSlot) + else + if EquipCursorItem then + EquipCursorItem(invSlot) + elseif PutItemInBag then + PutItemInBag(invSlot) + end + end + + if button then Guda_BagSlot_Update(button, bagID) end + if BagFrame and BagFrame.Update then BagFrame:Update() end +end + -- OnLoad handler for bag slot buttons function Guda_BagSlot_OnLoad(button, bagID) -- Hide borders from ItemButtonTemplate @@ -3094,13 +3133,7 @@ function Guda_BagSlot_OnLoad(button, bagID) button:SetScript("OnReceiveDrag", function() if this and this.bagID and this.bagID ~= 0 and CursorHasItem and CursorHasItem() then local inv = ContainerIDToInventoryID(this.bagID) - if EquipCursorItem then - EquipCursorItem(inv) - elseif PutItemInBag then - PutItemInBag(inv) - end - Guda_BagSlot_Update(this, this.bagID) - if BagFrame and BagFrame.Update then BagFrame:Update() end + Guda_TryEquipBagOnSlot(this.bagID, inv, this) end end) end @@ -3227,14 +3260,7 @@ function Guda_BagSlot_OnClick(button, bagID) if which == "LeftButton" then if bagID ~= 0 and CursorHasItem and CursorHasItem() then local invSlot = ContainerIDToInventoryID(bagID) - if EquipCursorItem then - EquipCursorItem(invSlot) - else - if PutItemInBag then PutItemInBag(invSlot) end - end - -- Update visuals after attempted equip - Guda_BagSlot_Update(button, bagID) - BagFrame:Update() + Guda_TryEquipBagOnSlot(bagID, invSlot, button) end return end