Files
2026-04-15 18:42:51 +04:00

584 lines
22 KiB
Lua

--===========================================================================
-- BagReplacer: Auto-replace occupied bags by evacuating items first
--===========================================================================
local addon = Guda
local BagReplacer = {}
addon.Modules.BagReplacer = BagReplacer
BagReplacer.inProgress = false
BagReplacer.slowMode = false -- true while replacing a bank bag (bank writes are server-validated and slower)
local C = addon.Constants
-- Time scaler: bank operations need longer debounce/timeout because the server
-- authoritatively validates every slot write (locks, bag updates) with round-trip delay.
local function T(seconds)
if BagReplacer.slowMode then
return seconds * 2.5
end
return seconds
end
--===========================================================================
-- 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, minDelay)
minDelay = minDelay or T(0.15)
timeout = timeout or T(1.5)
Guda_ScheduleTimer(minDelay, function()
if not AnyPendingLocked() then
ClearPendingLocks()
callback()
return
end
waitingForLocks = true
onLocksCleared = callback
local remaining = timeout - minDelay
if remaining < 0.3 then remaining = 0.3 end
Guda_ScheduleTimer(remaining, function()
if waitingForLocks then
waitingForLocks = false
onLocksCleared = nil
ClearPendingLocks()
ClearCursor()
callback()
end
end)
end)
end
local BATCH_SIZE = 5 -- moves per batch for evacuation
--===========================================================================
-- 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, isBank)
local freeSlots = {}
local count = 0
local function scan(bagID)
if bagID == excludeBagID then return end
local numSlots = GetContainerNumSlots(bagID)
if not numSlots or numSlots == 0 then return end
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
if isBank then
-- Main bank container + equipped bank bags
scan(-1)
for bagID = C.BANK_FIRST, C.BANK_LAST do scan(bagID) end
else
-- Backpack + inventory bags
for bagID = 0, C.BAG_LAST do scan(bagID) 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, isBank)
local items, itemCount = GetBagItems(targetBagID)
addon:DebugSort("[BagReplacer] BuildEvacuationPlan: bag %d has %d items (isBank=%s)", targetBagID, itemCount, tostring(isBank))
if itemCount == 0 then
return { moves = {}, stashBag = nil, stashSlot = nil, itemCount = 0, isBank = isBank }, nil
end
local freeSlots, freeCount = GetAvailableFreeSlots(targetBagID, isBank)
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,
targetBagID = targetBagID,
isBank = isBank
}, 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
self.slowMode = 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
--===========================================================================
local function FinalizeReplacement(plan)
ClearPendingLocks()
waitingForLocks = false
onLocksCleared = nil
BagReplacer.inProgress = false
BagReplacer.slowMode = false
addon:Print(Guda_L["Bag replaced successfully!"])
-- Invalidate scanner cache BEFORE update so it does a full rescan
-- (bag slot count changed, incremental update can't handle that)
if addon.Modules.BagScanner and addon.Modules.BagScanner.InvalidateCache then
addon.Modules.BagScanner:InvalidateCache()
end
if plan and plan.isBank
and addon.Modules.BankScanner and addon.Modules.BankScanner.InvalidateCache then
addon.Modules.BankScanner:InvalidateCache()
end
-- Refresh UI
if addon.Modules.BagFrame and addon.Modules.BagFrame.Update then
addon.Modules.BagFrame:Update()
end
if plan and plan.isBank
and addon.Modules.BankFrame and addon.Modules.BankFrame.Update then
addon.Modules.BankFrame:Update()
end
end
-- Wait for BAG_UPDATE on the target bag to confirm the server processed the equip,
-- then run the completion callback. Safety timeout if event never fires.
local bagUpdateFrame = CreateFrame("Frame")
local bagUpdateCallback = nil
local bagUpdateTarget = nil
bagUpdateFrame:SetScript("OnEvent", function()
if bagUpdateCallback and arg1 == bagUpdateTarget then
addon:DebugSort("[BagReplacer] BAG_UPDATE received for bag %d", arg1)
bagUpdateFrame:UnregisterEvent("BAG_UPDATE")
local cb = bagUpdateCallback
bagUpdateCallback = nil
bagUpdateTarget = nil
-- Small delay to let all related events settle
Guda_ScheduleTimer(T(0.15), cb)
end
end)
local function WaitForBagUpdate(targetBagID, callback)
bagUpdateCallback = callback
bagUpdateTarget = targetBagID
bagUpdateFrame:RegisterEvent("BAG_UPDATE")
-- Safety timeout
Guda_ScheduleTimer(T(1.5), function()
if bagUpdateCallback then
addon:DebugSort("[BagReplacer] BAG_UPDATE timeout for bag %d, proceeding", targetBagID)
bagUpdateFrame:UnregisterEvent("BAG_UPDATE")
local cb = bagUpdateCallback
bagUpdateCallback = nil
bagUpdateTarget = nil
cb()
end
end)
end
function BagReplacer:FinishReplacement(invSlot, plan)
local stashBag, stashSlot, targetBagID = plan.stashBag, plan.stashSlot, plan.targetBagID
addon:DebugSort("[BagReplacer] FinishReplacement: picking up new bag from bag%d/slot%d, equipping to invSlot %d (bag %d, isBank=%s)", stashBag, stashSlot, invSlot, targetBagID, tostring(plan.isBank))
-- 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(T(0.15), function()
if CursorHasItem and CursorHasItem() then
addon:DebugSort("[BagReplacer] Equipping new bag to invSlot %d", invSlot)
EquipCursorItem(invSlot)
-- Wait for BAG_UPDATE to confirm server processed the bag change
WaitForBagUpdate(targetBagID, 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
-- Use the same pool (bank or inventory) as the replacement
local freeSlots, freeCount = GetAvailableFreeSlots(-999, plan.isBank)
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
FinalizeReplacement(plan)
end)
else
BagReplacer:Abort("Bag replacement failed: could not pick up new bag.")
end
end)
end
--===========================================================================
-- ExecuteNextBatch: move items in batches for speed
--===========================================================================
function BagReplacer:ExecuteNextBatch(plan, index, invSlot)
local moves = plan.moves
local totalMoves = table.getn(moves)
-- Skip any already-empty source slots
while index <= totalMoves do
local sourceTexture = GetContainerItemInfo(moves[index].fromBag, moves[index].fromSlot)
if sourceTexture then break end
addon:DebugSort("[BagReplacer] Source slot empty, skipping move %d", index)
index = index + 1
end
-- All moves done - finish replacement
if index > totalMoves then
addon:DebugSort("[BagReplacer] All %d moves complete, finishing replacement", totalMoves)
self:FinishReplacement(invSlot, plan)
return
end
-- Check if first item in batch is locked (wait if so)
local firstMove = moves[index]
local _, _, locked = GetContainerItemInfo(firstMove.fromBag, firstMove.fromSlot)
if locked then
addon:DebugSort("[BagReplacer] Source bag%d/slot%d locked, waiting", firstMove.fromBag, firstMove.fromSlot)
AddPendingLock(firstMove.fromBag, firstMove.fromSlot)
WaitForLocksCleared(function()
BagReplacer:ExecuteNextBatch(plan, index, invSlot)
end)
return
end
-- Execute a batch of moves
local batchEnd = index + BATCH_SIZE - 1
if batchEnd > totalMoves then batchEnd = totalMoves end
local moved = 0
for i = index, batchEnd do
local move = moves[i]
-- Verify source still has an item
local sourceTexture = GetContainerItemInfo(move.fromBag, move.fromSlot)
if sourceTexture then
local _, _, sLocked = GetContainerItemInfo(move.fromBag, move.fromSlot)
if not sLocked then
PickupContainerItem(move.fromBag, move.fromSlot)
PickupContainerItem(move.toBag, move.toSlot)
ClearCursor()
AddPendingLock(move.fromBag, move.fromSlot)
AddPendingLock(move.toBag, move.toSlot)
moved = moved + 1
addon:DebugSort("[BagReplacer] Moved %d/%d: bag%d/slot%d -> bag%d/slot%d", i, totalMoves, move.fromBag, move.fromSlot, move.toBag, move.toSlot)
else
addon:DebugSort("[BagReplacer] Slot %d locked mid-batch, stopping batch", i)
batchEnd = i - 1
break
end
else
addon:DebugSort("[BagReplacer] Source slot %d empty, skipping", i)
end
end
local nextIndex = batchEnd + 1
addon:DebugSort("[BagReplacer] Batch done: %d items moved, next index %d/%d", moved, nextIndex, totalMoves)
if moved > 0 then
-- Wait for batch locks to clear, then next batch
WaitForLocksCleared(function()
BagReplacer:ExecuteNextBatch(plan, nextIndex, invSlot)
end)
else
-- Nothing moved (all empty/locked), try next batch immediately
BagReplacer:ExecuteNextBatch(plan, nextIndex, invSlot)
end
end
--===========================================================================
-- Execute: main entry point
--===========================================================================
function BagReplacer:Execute(targetBagID, invSlot, isBank)
addon:DebugSort("[BagReplacer] Execute: targetBagID=%d, invSlot=%d, isBank=%s", targetBagID, invSlot, tostring(isBank))
-- Guard: already in progress
if self.inProgress then
addon:DebugSort("[BagReplacer] Blocked: replacement already in progress")
addon:Print(Guda_L["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(Guda_L["Cannot replace bag while sorting is in progress."])
return
end
-- Guard: combat
if UnitAffectingCombat("player") then
addon:DebugSort("[BagReplacer] Blocked: in combat")
addon:Print(Guda_L["Cannot replace bag during combat."])
return
end
-- Guard: bank must be open for bank-bag swaps (server rejects bank writes otherwise)
if isBank and addon.Modules.BankScanner
and not addon.Modules.BankScanner:IsBankOpen() then
addon:DebugSort("[BagReplacer] Blocked: bank not open")
addon:Print("Cannot replace bank bag: bank is not open.")
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, isBank)
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
self.slowMode = isBank and true or false
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:ExecuteNextBatch(plan, 1, invSlot)
end)
end