Files
Guda-ClassicAPI/Sorting/SortEngine.lua
T

2084 lines
72 KiB
Lua

-- Guda Sort Engine
-- 6-Phase Advanced Sorting Algorithm for WoW 1.12.1 / Turtle WoW
local addon = Guda
local SortEngine = {}
addon.Modules.SortEngine = SortEngine
-- Flag to track if sorting is currently in progress
SortEngine.sortingInProgress = false
-- Performance: Max items to move per cycle
-- Smaller batches prevent overwhelming the server with rapid moves,
-- which can leave items permanently stuck in a locked (greyed-out) state.
local MAX_MOVES_PER_CYCLE = 10
-- Bank needs slightly more due to larger capacity (240 vs 160 slots)
local MAX_BANK_MOVES_PER_CYCLE = 12
-- Current sort context (set by ExecuteSort, used by ApplySort)
local currentSortType = "bags"
-- Swap deduplication: track swapped slot pairs from the PREVIOUS pass only
-- Prevents oscillation where pass N swaps A↔B and pass N+1 swaps B↔A
-- Only blocks immediate re-swaps; allows the same pair again after one pass gap
local previousPassSwaps = {}
local currentPassSwaps = {}
-- Generate a canonical key for a swap pair (order-independent)
local function SwapKey(bag1, slot1, bag2, slot2)
local a = bag1 * 1000 + slot1
local b = bag2 * 1000 + slot2
if a > b then a, b = b, a end
return a .. ":" .. b
end
-- Transfer status constants (like Baganator's SortStatus)
local TransferStatus = {
Complete = 0, -- All moves done
WaitingMove = 1, -- Waiting for item move to complete
WaitingUnlock = 2, -- Item is locked, waiting
Partial = 3, -- Some moves done, more pending
}
--===========================================================================
-- LOCK TRACKING & EVENT-DRIVEN PASS SCHEDULING
-- Tracks which slots were recently moved so we can wait for server to
-- release their locks before starting the next sort pass.
--===========================================================================
local sortProcessFrame = CreateFrame("Frame")
local pendingLockSlots = {} -- flat array: {bagID1, slot1, bagID2, slot2, ...}
local pendingLockCount = 0
local pendingLockSet = {} -- bagID*1000+slot -> true (O(1) lookup)
local waitingForLocks = false
local onLocksCleared = nil -- callback when all pending locks clear
local function AddPendingLock(bagID, slot)
local key = bagID * 1000 + slot
if not pendingLockSet[key] then
pendingLockSet[key] = true
pendingLockCount = pendingLockCount + 1
pendingLockSlots[pendingLockCount * 2 - 1] = bagID
pendingLockSlots[pendingLockCount * 2] = slot
end
end
local function ClearPendingLocks()
for k in pairs(pendingLockSet) do pendingLockSet[k] = nil end
for i = 1, pendingLockCount * 2 do pendingLockSlots[i] = nil end
pendingLockCount = 0
end
local function AnyPendingLocked()
if pendingLockCount == 0 then return false end
for i = 1, pendingLockCount do
local bagID = pendingLockSlots[i * 2 - 1]
local slot = pendingLockSlots[i * 2]
if bagID and slot then
local _, _, locked = GetContainerItemInfo(bagID, slot)
if locked then return true end
end
end
return false
end
-- Event handler: when ITEM_LOCK_CHANGED fires, check if all pending locks cleared
sortProcessFrame:RegisterEvent("ITEM_LOCK_CHANGED")
sortProcessFrame:SetScript("OnEvent", function()
if waitingForLocks and onLocksCleared then
if not AnyPendingLocked() then
waitingForLocks = false
local cb = onLocksCleared
onLocksCleared = nil
ClearPendingLocks()
cb()
end
end
end)
-- Wait for all pending locks to clear, then call callback.
-- Always waits a minimum delay first to let the server process moves,
-- then uses ITEM_LOCK_CHANGED events with a fallback timeout.
local function WaitForLocksCleared(callback, timeout)
-- Always wait a minimum delay to let the server apply and process locks.
-- Without this, GetContainerItemInfo may not yet reflect the lock state,
-- causing us to proceed before the server has finished processing.
local minDelay = 0.5
Guda_ScheduleTimer(minDelay, function()
-- After minimum delay, check if locks have already cleared
if not AnyPendingLocked() then
ClearPendingLocks()
callback()
return
end
-- Still locked — wait for ITEM_LOCK_CHANGED events
waitingForLocks = true
onLocksCleared = callback
-- Safety timeout: if locks don't clear via events, proceed anyway
local remaining = (timeout or 2.0) - minDelay
if remaining < 0.5 then remaining = 0.5 end
Guda_ScheduleTimer(remaining, function()
if waitingForLocks then
addon:DebugSort("Lock wait timed out after %.1fs, proceeding anyway", timeout or 2.0)
waitingForLocks = false
onLocksCleared = nil
ClearPendingLocks()
ClearCursor()
callback()
end
end)
end)
end
-- Update sort button appearance based on sorting state
function SortEngine:UpdateSortButtonState(isDisabled)
local buttons = {
getglobal("Guda_BagFrame_SortButton"),
getglobal("Guda_BankFrame_SortButton")
}
for _, btn in ipairs(buttons) do
if btn then
local icon = getglobal(btn:GetName() .. "_Icon")
if icon then
if isDisabled then
icon:SetVertexColor(0.4, 0.4, 0.4)
else
icon:SetVertexColor(0.8, 0.8, 0.8)
end
end
end
end
end
--===========================================================================
-- CONSTANTS
--===========================================================================
-- Turtle WoW GetItemInfo signature:
-- itemName, itemLink, itemRarity, itemLevel, itemCategory, itemType, itemStackCount,
-- itemSubType, itemTexture, itemEquipLoc, itemSellPrice = GetItemInfo(itemID)
-- Priority items that should always be sorted first
local PRIORITY_ITEMS = {
[6948] = 1, -- Hearthstone (highest priority)
}
-- Equipment slot ordering (for sorting equippable gear by slot)
local EQUIP_SLOT_ORDER = {
["INVTYPE_WEAPONMAINHAND"] = 1,
["INVTYPE_WEAPONOFFHAND"] = 2,
["INVTYPE_WEAPON"] = 3,
["INVTYPE_2HWEAPON"] = 4,
["INVTYPE_SHIELD"] = 5,
["INVTYPE_HOLDABLE"] = 6,
["INVTYPE_HEAD"] = 7,
["INVTYPE_NECK"] = 8,
["INVTYPE_SHOULDER"] = 9,
["INVTYPE_CLOAK"] = 10,
["INVTYPE_CHEST"] = 11,
["INVTYPE_ROBE"] = 11,
["INVTYPE_WRIST"] = 12,
["INVTYPE_HAND"] = 13,
["INVTYPE_WAIST"] = 14,
["INVTYPE_LEGS"] = 15,
["INVTYPE_FEET"] = 16,
["INVTYPE_FINGER"] = 17,
["INVTYPE_TRINKET"] = 18,
["INVTYPE_RANGED"] = 19,
["INVTYPE_RANGEDRIGHT"] = 20,
["INVTYPE_THROWN"] = 21,
["INVTYPE_RELIC"] = 22,
["INVTYPE_BODY"] = 23,
["INVTYPE_TABARD"] = 24,
["INVTYPE_BAG"] = 25,
["INVTYPE_QUIVER"] = 26,
}
-- Category sorting order (1 is reserved for equippable gear)
local CATEGORY_ORDER = {
["Consumable"] = 2,
["Projectile"] = 3,
["Weapon"] = 4, -- Non-equippable weapons
["Armor"] = 5, -- Non-equippable armor
["Tools"] = 6,
["Quest"] = 7,
["Quiver"] = 8,
["Reagent"] = 9,
["Trade Goods"] = 10,
["Recipe"] = 11,
["Container"] = 12,
["Key"] = 14,
["Miscellaneous"] = 15,
["Junk"] = 17,
["Class Items"] = 18,
}
-- Weapon subclass ordering: 1H weapons grouped first, then 2H, then ranged
-- Includes both singular and plural forms for TurtleWoW API compatibility
local WEAPON_SUBCLASS_ORDER = {
["Dagger"] = 1, ["Daggers"] = 1,
["Fist Weapon"] = 2, ["Fist Weapons"] = 2,
["One-Handed Sword"] = 3, ["One-Handed Swords"] = 3,
["One-Handed Mace"] = 4, ["One-Handed Maces"] = 4,
["One-Handed Axe"] = 5, ["One-Handed Axes"] = 5,
["Sword"] = 6, ["Swords"] = 6,
["Mace"] = 7, ["Maces"] = 7,
["Axe"] = 8, ["Axes"] = 8,
["Two-Handed Sword"] = 10, ["Two-Handed Swords"] = 10,
["Two-Handed Mace"] = 11, ["Two-Handed Maces"] = 11,
["Two-Handed Axe"] = 12, ["Two-Handed Axes"] = 12,
["Polearm"] = 13, ["Polearms"] = 13,
["Staff"] = 14, ["Staves"] = 14,
["Fishing Pole"] = 15, ["Fishing Poles"] = 15,
["Bow"] = 18, ["Bows"] = 18,
["Crossbow"] = 19, ["Crossbows"] = 19,
["Gun"] = 20, ["Guns"] = 20,
["Wand"] = 21, ["Wands"] = 21,
["Thrown"] = 22,
}
-- Armor subclass ordering: heavy armor first, then light, then accessories
local ARMOR_SUBCLASS_ORDER = {
["Plate"] = 1,
["Mail"] = 2,
["Leather"] = 3,
["Cloth"] = 4,
["Shield"] = 5, ["Shields"] = 5,
["Libram"] = 6, ["Librams"] = 6,
["Idol"] = 7, ["Idols"] = 7,
["Totem"] = 8, ["Totems"] = 8,
["Buckler"] = 9, ["Bucklers"] = 9,
["Miscellaneous"] = 10,
}
-- Subclass ordering for grouping related items
local SUBCLASS_ORDER = {
gems = 1,
cloth = 2,
leather = 3,
herbs = 4,
elemental = 5,
enchanting = 6,
engineering = 7,
consumable = 8,
other = 99,
}
-- Gem name patterns for detection
local GEM_PATTERNS = {
"ruby", "sapphire", "emerald", "diamond", "jade", "citrine",
"aquamarine", "agate", "pearl", "topaz", "opal", "malachite",
"tigerseye", "tiger's eye", "shadowgem"
}
--===========================================================================
-- UTILITY FUNCTIONS
-- Uses shared tooltip from Utils module
--===========================================================================
-- Check if an item can be placed in a target bag (bag family constraint validation)
local function CanItemGoInBag(itemLink, targetBagID)
-- Backpack, bank main, and keyring accept all items
if targetBagID == 0 or targetBagID == -1 or targetBagID == -2 then
return true
end
local bagType = addon.Modules.Utils:GetSpecializedBagType(targetBagID)
-- Regular bags accept all items
if not bagType then
return true
end
-- Specialized bags: check if item matches the bag's type
local preferredType = addon.Modules.Utils:GetItemPreferredContainer(itemLink)
return preferredType == bagType
end
-- Extract item ID from an item link string
local function GetItemIDFromLink(link)
if not link then return nil end
local _, _, id = string.find(link, "item:(%d+)")
return id
end
-- Property cache to prevent race conditions during rapid moves
-- With size limits to prevent unbounded memory growth (Baganator pattern)
local propertyCache = {}
local propertyCacheSize = 0
local PROPERTY_CACHE_MAX = 500 -- Prevent memory leaks from unbounded cache
function SortEngine:ClearCache()
propertyCache = {}
propertyCacheSize = 0
end
-- Get cache statistics for performance monitoring
function SortEngine:GetCacheStats()
return {
size = propertyCacheSize,
maxSize = PROPERTY_CACHE_MAX,
}
end
local function GetItemProperties(bagID, slotID, itemLink)
if not itemLink then return nil end
-- Extract base item link for stable caching (item:ID:Enchant:...)
local _, _, baseLink = string.find(itemLink, "(item:%d+:%d+:%d+:%d+:%d+:%d+:%d+:%d+)")
local cacheKey = baseLink or itemLink
if propertyCache[cacheKey] then
return propertyCache[cacheKey]
end
-- Evict cache if it exceeds max size to prevent memory leaks
if propertyCacheSize >= PROPERTY_CACHE_MAX then
propertyCache = {}
propertyCacheSize = 0
end
local props = {
isQuest = false,
isQuestStarter = false,
isQuestUsable = false,
isPermanentEnchant = false, -- "Use: Permanently adds" items
isGray = false,
restoreTag = nil
}
-- Quality check from link color code (|cff9d9d9d is gray)
if string.find(itemLink, "|cff9d9d9d") then
props.isGray = true
end
if bagID and slotID then
-- Use shared tooltip from Utils module
local scanTooltip, tooltipName = addon.Modules.Utils:GetScanTooltip()
scanTooltip:ClearLines()
scanTooltip:SetBagItem(bagID, slotID)
local numLines = scanTooltip:NumLines()
if numLines and numLines > 0 then
for i = 1, numLines do
local line = getglobal(tooltipName .. "TextLeft" .. i)
if line then
local text = line:GetText()
if text then
local tl = string.lower(text)
-- Quest item check
if string.find(tl, "quest starter") or
string.find(tl, "this item begins a quest") or
string.find(tl, "starts a quest") or
string.find(tl, "quest item") or
string.find(tl, "manual") then
props.isQuest = true
end
-- Starter check
if string.find(tl, "quest starter") or
string.find(tl, "this item begins a quest") or
string.find(tl, "starts a quest") then
props.isQuestStarter = true
end
-- Usable check
if string.find(tl, "use:") or
string.find(tl, "right%-click") or
string.find(tl, "right click") or
string.find(tl, "click to") then
props.isQuestUsable = true
end
-- Permanent enchant check (scrolls with "Permanently...")
-- Check for "permanently" anywhere in the line (green text doesn't have "Use:" prefix)
if string.find(tl, "permanently") then
props.isPermanentEnchant = true
end
-- Restore tag check (higher priority tags override lower ones)
if string.find(tl, "while eating") then
props.restoreTag = "eat"
elseif string.find(tl, "while drinking") then
if props.restoreTag ~= "eat" then
props.restoreTag = "drink"
end
elseif string.find(tl, "use: restores") then
if not props.restoreTag then
props.restoreTag = "restore"
end
end
-- Gray check (header line color fallback)
if i == 1 and string.find(text, "|cff9d9d9d") then
props.isGray = true
end
end
end
end
end
end
propertyCache[cacheKey] = props
propertyCacheSize = propertyCacheSize + 1
return props
end
-- Check if an item is a quest item by scanning its tooltip
-- Uses centralized ItemDetection when available
local function IsQuestItemTooltip(bagID, slotID, itemData)
if not bagID or not slotID then return false end
-- Use ItemDetection if available
if addon.Modules.ItemDetection and itemData then
local props = addon.Modules.ItemDetection:GetItemProperties(itemData, bagID, slotID)
return props.isQuestItem
end
-- Fallback to local GetItemProperties
local link = GetContainerItemLink(bagID, slotID)
local props = GetItemProperties(bagID, slotID, link)
return props and props.isQuest or false
end
-- Check if a quest item is usable (has 'Use:' or click to text)
-- Uses centralized ItemDetection when available
local function IsQuestItemUsable(bagID, slotID, itemData)
if not bagID or not slotID then return false end
-- Use ItemDetection if available
if addon.Modules.ItemDetection and itemData then
local props = addon.Modules.ItemDetection:GetItemProperties(itemData, bagID, slotID)
return props.isQuestUsable
end
-- Fallback to local GetItemProperties
local link = GetContainerItemLink(bagID, slotID)
local props = GetItemProperties(bagID, slotID, link)
return props and props.isQuestUsable or false
end
-- Check if item is a quest starter (explicit 'Starts a Quest' or 'This Item Begins a Quest')
-- Uses centralized ItemDetection when available
local function IsQuestItemStarter(bagID, slotID, itemData)
if not bagID or not slotID then return false end
-- Use ItemDetection if available
if addon.Modules.ItemDetection and itemData then
local props = addon.Modules.ItemDetection:GetItemProperties(itemData, bagID, slotID)
return props.isQuestStarter
end
-- Fallback to local GetItemProperties
local link = GetContainerItemLink(bagID, slotID)
local props = GetItemProperties(bagID, slotID, link)
return props and props.isQuestStarter or false
end
-- Check if an item has a gray title in its tooltip or link
local function IsItemGrayTooltip(bagID, slotID, itemLink)
local props = GetItemProperties(bagID, slotID, itemLink)
return props and props.isGray or false
end
-- Check if an item has a restore tag (eat, drink, or restore)
local function GetItemRestoreTagTooltip(bagID, slotID)
if not bagID or not slotID then return nil end
local link = GetContainerItemLink(bagID, slotID)
local props = GetItemProperties(bagID, slotID, link)
return props and props.restoreTag or nil
end
-- Extract itemID from item link
local function GetItemID(link)
if not link then return 0 end
local _, _, itemID = string.find(link, "item:(%d+)")
return tonumber(itemID) or 0
end
-- Get item quality from link color code
local function GetQualityFromLink(link)
if not link then return 1 end
if string.find(link, "|cff9d9d9d") then return 0 end -- Gray
if string.find(link, "|cffffffff") then return 1 end -- White
if string.find(link, "|cff1eff00") then return 2 end -- Green
if string.find(link, "|cff0070dd") then return 3 end -- Blue
if string.find(link, "|cffa335ee") then return 4 end -- Purple
if string.find(link, "|cffff8000") then return 5 end -- Orange
if string.find(link, "|cffe6cc80") then return 6 end -- Red/Artifact
return 1
end
-- Check if an item is a profession tool (should NOT be treated as junk)
local function IsProfessionTool(itemLink, itemSubclass)
-- Check by item ID
if itemLink then
local itemID = GetItemID(itemLink)
if itemID and addon.Constants.PROFESSION_TOOL_IDS and addon.Constants.PROFESSION_TOOL_IDS[itemID] then
return true
end
end
-- Check by subtype (e.g., Fishing Pole)
if itemSubclass and addon.Constants.PROFESSION_TOOL_SUBTYPES and addon.Constants.PROFESSION_TOOL_SUBTYPES[itemSubclass] then
return true
end
return false
end
-- Check if an item has special tooltip text (Use:, Equip:, green text)
-- These items should NOT be treated as junk
local function HasSpecialTooltipText(bagID, slotID, itemLink)
if addon.Modules.Utils and addon.Modules.Utils.HasSpecialTooltipText then
return addon.Modules.Utils:HasSpecialTooltipText(bagID, slotID, itemLink)
end
return false
end
-- Extract texture pattern for grouping similar items
local function GetTexturePattern(textureName)
if not textureName then return "" end
-- Remove "Interface\\Icons\\INV_" prefix if present
local cleaned = string.gsub(textureName, "^Interface\\Icons\\INV_", "")
-- Remove trailing numbers and underscores (like _01, _03, etc.)
cleaned = string.gsub(cleaned, "_%d+$", "") -- Remove trailing _01, _02, etc.
cleaned = string.gsub(cleaned, "_$", "") -- Remove trailing underscore if any
return cleaned
end
-- Check if an item is a mount by texture path
function SortEngine.IsMount(itemTexture)
if not itemTexture then return false end
local textureLower = string.lower(itemTexture)
-- Check for mount patterns in texture path
-- Mount textures typically contain "mount" or "ability_mount"
if string.find(textureLower, "_mount_") then
return true
end
return false
end
local IsMount = SortEngine.IsMount
-- Determine subclass order for grouping related items
local function GetSubclassOrder(subclass, itemName)
if not subclass then return 50 end
local subLower = string.lower(subclass)
local nameLower = itemName and string.lower(itemName) or ""
-- Check for gems (by subclass or name)
if string.find(subLower, "metal") or string.find(subLower, "stone") or string.find(subLower, "gem") then
return SUBCLASS_ORDER.gems
end
for _, gemPattern in ipairs(GEM_PATTERNS) do
if string.find(nameLower, gemPattern) then
return SUBCLASS_ORDER.gems
end
end
-- Check other categories
-- Group metals, stones, ores, bars together with gems
if string.find(subLower, "metal") or string.find(subLower, "stone") or
string.find(nameLower, "ore") or string.find(nameLower, "bar") or string.find(nameLower, "stone") then
return SUBCLASS_ORDER.gems -- Group ores/bars/stones with gems
end
if string.find(subLower, "cloth") then return SUBCLASS_ORDER.cloth end
if string.find(subLower, "leather") then return SUBCLASS_ORDER.leather end
if string.find(subLower, "herb") then return SUBCLASS_ORDER.herbs end
if string.find(subLower, "elemental") then return SUBCLASS_ORDER.elemental end
if string.find(subLower, "enchanting") then return SUBCLASS_ORDER.enchanting end
if string.find(subLower, "engineering") or string.find(subLower, "parts") or string.find(subLower, "device") then
return SUBCLASS_ORDER.engineering
end
if string.find(subLower, "consumable") or string.find(subLower, "food") or string.find(subLower, "drink") then
return SUBCLASS_ORDER.consumable
end
if string.find(subLower, "trade goods") or string.find(subLower, "other") then
return SUBCLASS_ORDER.other
end
return 50 -- Default for unknown types
end
--===========================================================================
-- PHASE 1: Special Container Detection
--===========================================================================
local function DetectSpecializedBags(bagIDs)
local containers = {
soul = {},
herb = {},
enchant = {},
quiver = {},
ammo = {},
regular = {}
}
for _, bagID in ipairs(bagIDs) do
local bagType = addon.Modules.Utils:GetSpecializedBagType(bagID)
if bagType == "soul" then
table.insert(containers.soul, bagID)
elseif bagType == "herb" then
table.insert(containers.herb, bagID)
elseif bagType == "enchant" then
table.insert(containers.enchant, bagID)
elseif bagType == "quiver" then
table.insert(containers.quiver, bagID)
elseif bagType == "ammo" then
table.insert(containers.ammo, bagID)
else
table.insert(containers.regular, bagID)
end
end
return containers
end
--===========================================================================
-- PHASE 2: Specialized Item Routing
--===========================================================================
local function RouteSpecializedItems(bagIDs, containers)
local routingPlan = {}
-- Scan all items and plan moves to specialized containers
for _, bagID in ipairs(bagIDs) do
local numSlots = addon.Modules.Utils:GetBagSlotCount(bagID)
if numSlots and numSlots > 0 then
for slot = 1, numSlots do
local link = GetContainerItemLink(bagID, slot)
if link then
local preferredType = addon.Modules.Utils:GetItemPreferredContainer(link)
local currentBagType = addon.Modules.Utils:GetSpecializedBagType(bagID)
-- Route item if it has a preferred container and isn't already in one
if preferredType and currentBagType ~= preferredType then
local targetBags = containers[preferredType]
if targetBags and table.getn(targetBags) > 0 then
-- Find first available slot in preferred containers
local foundSlot = false
for _, targetBagID in ipairs(targetBags) do
if not foundSlot then
local targetSlots = addon.Modules.Utils:GetBagSlotCount(targetBagID)
for targetSlot = 1, targetSlots do
if not GetContainerItemLink(targetBagID, targetSlot) then
table.insert(routingPlan, {
fromBag = bagID,
fromSlot = slot,
toBag = targetBagID,
toSlot = targetSlot
})
foundSlot = true
break
end
end
end
end
end
end
end
end
end
end
-- Execute routing plan
for _, move in ipairs(routingPlan) do
PickupContainerItem(move.fromBag, move.fromSlot)
PickupContainerItem(move.toBag, move.toSlot)
ClearCursor()
AddPendingLock(move.fromBag, move.fromSlot)
AddPendingLock(move.toBag, move.toSlot)
end
return table.getn(routingPlan)
end
--===========================================================================
-- PHASE 3: Stack Consolidation
--===========================================================================
local function ConsolidateStacks(bagIDs)
local itemGroups = {}
-- Collect all items with their locations
for _, bagID in ipairs(bagIDs) do
local numSlots = addon.Modules.Utils:GetBagSlotCount(bagID)
if numSlots and numSlots > 0 then
for slot = 1, numSlots do
local link = GetContainerItemLink(bagID, slot)
if link then
local texture, count = GetContainerItemInfo(bagID, slot)
local itemID = GetItemID(link)
local _, _, itemRarity = GetItemInfo(itemID)
-- Group by itemID and rarity
local groupKey = itemID .. "_" .. (tonumber(itemRarity) or 0)
if not itemGroups[groupKey] then
itemGroups[groupKey] = {
itemID = itemID,
link = link,
stacks = {}
}
end
table.insert(itemGroups[groupKey].stacks, {
bagID = bagID,
slot = slot,
count = count or 1,
priority = tonumber(addon.Modules.Utils:GetContainerPriority(bagID)) or 0
})
end
end
end
end
-- Consolidate stacks for each group
local consolidationMoves = 0
for _, group in pairs(itemGroups) do
if table.getn(group.stacks) > 1 then
local itemID = GetItemID(group.link)
local _, _, _, _, _, _, itemStackCount = GetItemInfo(itemID)
local maxStack = tonumber(itemStackCount) or 1
if maxStack > 1 then
-- Sort stacks: higher priority bags first, then larger stacks
table.sort(group.stacks, function(a, b)
if not a then return false end
if not b then return true end
if (a.priority or 0) ~= (b.priority or 0) then
return (a.priority or 0) > (b.priority or 0)
end
return (a.count or 0) > (b.count or 0)
end)
-- Greedy consolidation: fill stacks from left to right
for i = 1, table.getn(group.stacks) do
local source = group.stacks[i]
if source.count < maxStack then
for j = i + 1, table.getn(group.stacks) do
local target = group.stacks[j]
if target.count > 0 then
local spaceAvailable = maxStack - source.count
local amountToMove = math.min(spaceAvailable, target.count)
if amountToMove > 0 then
if amountToMove < target.count then
SplitContainerItem(target.bagID, target.slot, amountToMove)
PickupContainerItem(source.bagID, source.slot)
else
PickupContainerItem(target.bagID, target.slot)
PickupContainerItem(source.bagID, source.slot)
end
ClearCursor()
AddPendingLock(target.bagID, target.slot)
AddPendingLock(source.bagID, source.slot)
source.count = source.count + amountToMove
target.count = target.count - amountToMove
consolidationMoves = consolidationMoves + 1
if source.count >= maxStack then
break
end
end
end
end
end
end
end
end
end
return consolidationMoves
end
--===========================================================================
-- PHASE 4: Categorical Sorting
--===========================================================================
local function AddSortKeys(items)
for _, item in ipairs(items) do
if item.data and item.data.link then
local itemID = GetItemID(item.data.link)
-- Use data collected during the initial scan (item.data) for absolute stability
local d = item.data
local itemName = d.name
local itemLink = d.link
local itemRarity = d.quality
local itemLevel = d.iLevel
local itemCategory = d.class
local itemType = d.type
local itemSubType = d.subclass
local itemTexture = d.texture
local itemStackSize = d.stackSize or 1
item.itemName = itemName or ""
if not itemName then
-- Skip items that couldn't be loaded (fallback safety)
item.sortedClass = 999
item.isEquippable = false
item.priority = 1000
item.equipSlotOrder = 999
item.invertedQuality = 0
item.invertedItemLevel = 0
item.sortedSubclass = 999
item.subclass = ""
item.texturePattern = ""
item.invertedCount = 0
item.invertedItemID = 0
item.maxStackCount = 1
item.isStackable = false
item.stackCount = 1
else
-- Check if item is equippable (Armor or Weapon category)
local isEquippable = itemCategory == "Armor" or itemCategory == "Weapon"
-- Check if item is a mount (by texture path)
local isMount = IsMount(itemTexture)
-- Priority (1 = hearthstone, 2 = mounts, 1000 = everything else)
if PRIORITY_ITEMS[itemID] then
item.priority = PRIORITY_ITEMS[itemID]
elseif isMount then
item.priority = 2
else
item.priority = 1000
end
item.isEquippable = isEquippable
item.isMount = isMount
-- Class and slot ordering
-- Check for items that should be treated as junk
-- Use centralized ItemDetection when available
local isJunkItem = false
if addon.Modules.ItemDetection then
local props = addon.Modules.ItemDetection:GetItemProperties(item.data, item.bagID, item.slot)
isJunkItem = props.isJunk
else
-- Fallback: manual junk detection
local isGrayItem = itemRarity == 0 or IsItemGrayTooltip(item.bagID, item.slot, item.data.link)
local isWhiteEquip = false
if itemRarity == 1 and (itemCategory == "Weapon" or itemCategory == "Armor") then
addon:Debug("SortEngine isJunk: name='%s', subclass='%s', class='%s'",
tostring(itemName), tostring(itemSubType), tostring(itemCategory))
local isSpecialSlot = (itemSubType == "INVTYPE_TRINKET" or
itemSubType == "INVTYPE_FINGER" or
itemSubType == "INVTYPE_NECK" or
itemSubType == "INVTYPE_TABARD" or
itemSubType == "INVTYPE_BODY")
if not isSpecialSlot then
local isProfTool = IsProfessionTool(item.data.link, itemSubType)
local hasSpecialText = false
if not isProfTool then
hasSpecialText = HasSpecialTooltipText(item.bagID, item.slot, item.data.link)
end
if not isProfTool and not hasSpecialText then
isWhiteEquip = true
end
end
end
isJunkItem = isGrayItem or isWhiteEquip
end
if isJunkItem then
item.sortedClass = CATEGORY_ORDER["Junk"] or 99
item.equipSlotOrder = 999
item.isEquippable = false -- Treat junk gear as junk, not gear
elseif isEquippable then
item.sortedClass = 1 -- All equippable gear gets priority class
item.equipSlotOrder = EQUIP_SLOT_ORDER[itemSubType] or 999
else
-- Override misclassified Quest items (e.g. Juju consumables) to Consumable
if itemCategory == "Quest" and (addon.Constants.QUEST_CATEGORY_EXCLUSIONS and addon.Constants.QUEST_CATEGORY_EXCLUSIONS[itemID]) then
item.sortedClass = CATEGORY_ORDER["Consumable"] or 2
else
item.sortedClass = CATEGORY_ORDER[itemCategory] or 99
end
-- Check if item is a permanent enchant (should NOT be Quest)
local itemProps = GetItemProperties(item.bagID, item.slot, item.data.link)
local isPermanentEnchant = itemProps and itemProps.isPermanentEnchant or false
-- Permanent enchant items always sort BEFORE quest items (category 6)
-- regardless of their itemCategory or itemType
if isPermanentEnchant then
item.sortedClass = 6 -- Same as Tools, comes before Quest (7)
-- Heuristic: Detect items that should be in the Quest category (priority 7)
-- but aren't categorized as such by the game (e.g. some "Manual" items)
-- Skip items with a known non-Quest category (e.g. Consumable, Trade Goods)
elseif item.sortedClass ~= (CATEGORY_ORDER["Quest"] or 7) and not CATEGORY_ORDER[itemCategory] then
local nameLower = string.lower(item.itemName)
if string.find(nameLower, "manual") then
item.sortedClass = CATEGORY_ORDER["Quest"] or 7
elseif IsQuestItemTooltip(item.bagID, item.slot, item.data) then
item.sortedClass = CATEGORY_ORDER["Quest"] or 7
elseif addon.IsQuestItemByID then
-- Check QuestItemsDB for faction-specific quest items
local playerFaction = UnitFactionGroup("player")
if addon:IsQuestItemByID(itemID, playerFaction) then
item.sortedClass = CATEGORY_ORDER["Quest"] or 7
end
end
end
item.equipSlotOrder = 999
end
-- Subclass ordering
-- Detect consumable restore/eat/drink tag (if available) BEFORE computing subclass priority
item.restoreTag = GetItemRestoreTagTooltip(item.bagID, item.slot)
local baseSub = GetSubclassOrder(itemSubType, item.itemName)
local function _cons_prk(t)
if t == "eat" then return -300 end
if t == "drink" then return -200 end
if t == "restore" then return -100 end
return 0
end
item.sortedSubclass = _cons_prk(item.restoreTag) + baseSub
item.subclass = itemSubType or ""
-- Numeric subclass order for logical weapon/armor grouping
if itemCategory == "Weapon" and WEAPON_SUBCLASS_ORDER[itemSubType] then
item.numericSubclassOrder = WEAPON_SUBCLASS_ORDER[itemSubType]
elseif itemCategory == "Armor" and ARMOR_SUBCLASS_ORDER[itemSubType] then
item.numericSubclassOrder = ARMOR_SUBCLASS_ORDER[itemSubType]
else
item.numericSubclassOrder = 99
end
-- Quest flags: mark quest items and detect starter/usable states
-- Note: permanent enchant items should NOT be marked as quest items
item.isQuest = false
item.isQuestStarter = false
item.isQuestUsable = false
item.isPermanentEnchant = isPermanentEnchant -- Store for potential use in sorting
local nameLower = string.lower(item.itemName)
if not isPermanentEnchant and not (addon.Constants.QUEST_CATEGORY_EXCLUSIONS and addon.Constants.QUEST_CATEGORY_EXCLUSIONS[itemID]) then
if itemCategory == "Quest" or item.data.class == "Quest" or IsQuestItemTooltip(item.bagID, item.slot, item.data) then
item.isQuest = true
if IsQuestItemStarter(item.bagID, item.slot, item.data) then item.isQuestStarter = true end
if IsQuestItemUsable(item.bagID, item.slot, item.data) then item.isQuestUsable = true end
elseif addon.IsQuestItemByID then
-- Check QuestItemsDB for faction-specific quest items
local playerFaction = UnitFactionGroup("player")
if addon:IsQuestItemByID(itemID, playerFaction) then
item.isQuest = true
end
end
end
-- Texture pattern for grouping similar items (especially trade goods)
item.texturePattern = GetTexturePattern(itemTexture)
-- Group Trade Goods: meats (names ending with 'meat') before eggs
if itemType == "Trade Goods" then
if string.find(nameLower, "meat$") then
item.texturePattern = "trade_meat"
elseif string.find(nameLower, "egg") then
item.texturePattern = "trade_egg"
end
end
-- Inverted values for descending sorts
item.invertedQuality = -(tonumber(itemRarity) or 0)
item.invertedItemLevel = -(tonumber(itemLevel) or 0)
item.invertedCount = -(tonumber(item.data.count) or 1)
item.invertedItemID = -tonumber(itemID)
-- Stack info for reverse stack sorting
item.maxStackCount = tonumber(itemStackSize) or 1
item.isStackable = item.maxStackCount > 1
item.stackCount = tonumber(item.data.count) or 1
end
end
end
end
local function SortItems(items)
AddSortKeys(items)
-- Get reverse stack sort setting from DB
local reverseStackSort = false
if addon.Modules.DB and addon.Modules.DB.GetSetting then
reverseStackSort = addon.Modules.DB:GetSetting("reverseStackSort")
if reverseStackSort == nil then
reverseStackSort = false
end
end
table.sort(items, function(a, b)
-- Guard against nil entries
if not a then return false end
if not b then return true end
-- 1. Priority items first (Hearthstone, etc.)
if (a.priority or 0) ~= (b.priority or 0) then
return (a.priority or 0) < (b.priority or 0)
end
-- 2. Equippable items always come before non-equippable items
if a.isEquippable ~= b.isEquippable then
return a.isEquippable
end
-- 3. For equippable gear: sort by slot, subtype, texture pattern, quality, ilvl, name
if a.isEquippable then
if a.equipSlotOrder ~= b.equipSlotOrder then
return a.equipSlotOrder < b.equipSlotOrder
end
-- Group by subclass using numeric order (1H before 2H, plate before cloth)
local aOrder = a.numericSubclassOrder or 99
local bOrder = b.numericSubclassOrder or 99
if aOrder ~= bOrder then
return aOrder < bOrder
end
-- Fallback to string comparison for unknown subtypes
if a.subclass ~= b.subclass then
return a.subclass < b.subclass
end
-- Then group items with similar textures together
if a.texturePattern ~= b.texturePattern then
return a.texturePattern < b.texturePattern
end
if a.invertedQuality ~= b.invertedQuality then
return a.invertedQuality < b.invertedQuality
end
if a.invertedItemLevel ~= b.invertedItemLevel then
return a.invertedItemLevel < b.invertedItemLevel
end
if a.itemName ~= b.itemName then
return a.itemName < b.itemName
end
else
-- 4. For non-equippable items: prioritize consumable restoreTag, then class, subclass, texture pattern, quality, name
-- If either item has a restoreTag, compare their priority (eat>drink>restore>none)
local pa = a.restoreTag or nil
local pb = b.restoreTag or nil
local function pr(t)
if t == "eat" then return 3 end
if t == "drink" then return 2 end
if t == "restore" then return 1 end
return 0
end
if pr(pa) ~= pr(pb) then
return pr(pa) > pr(pb)
end
if a.sortedClass ~= b.sortedClass then
return a.sortedClass < b.sortedClass
end
-- If both items are in the Quest class, apply quest-specific ordering
-- If both items are detected as quest items, apply quest-specific ordering
if a.isQuest and b.isQuest then
local function qrank(it)
if it.isQuestStarter then return 3 end
if it.isQuestUsable then return 2 end
if it.isQuest then return 1 end
return 0
end
local ra, rb = qrank(a), qrank(b)
if ra ~= rb then
return ra > rb
end
elseif a.isQuest ~= b.isQuest then
-- If only one is a quest item and they are in the same sortedClass,
-- put the quest item first within that class (or just let sortedClass handle it)
-- Since we moved Quest to its own class 7, this might not be needed unless
-- they are in different classes but one is marked isQuest.
-- However, if they have same sortedClass, we want quest items first.
return a.isQuest
end
if a.sortedSubclass ~= b.sortedSubclass then
return a.sortedSubclass < b.sortedSubclass
end
if a.subclass ~= b.subclass then
return a.subclass < b.subclass
end
-- Group items with similar textures (e.g., all "Misc_Food", all "Fabric_Linen")
if a.texturePattern ~= b.texturePattern then
return a.texturePattern < b.texturePattern
end
if a.invertedQuality ~= b.invertedQuality then
return a.invertedQuality < b.invertedQuality
end
if a.itemName ~= b.itemName then
return a.itemName < b.itemName
end
end
-- 5. Final tiebreakers (group identical items together)
-- Sort by itemID first to ensure identical items are adjacent
if a.invertedItemID ~= b.invertedItemID then
return a.invertedItemID < b.invertedItemID
end
-- Then by item level
if a.invertedItemLevel ~= b.invertedItemLevel then
return a.invertedItemLevel < b.invertedItemLevel
end
-- Then by stack count
-- If reverse stack sort is enabled AND both items are the same stackable item,
-- place smaller stacks before larger ones
if a.invertedCount ~= b.invertedCount then
-- Check if both items are the same stackable item (same itemID)
if reverseStackSort and a.isStackable and b.isStackable and a.invertedItemID == b.invertedItemID then
-- Reverse: smaller stacks first (compare stackCount ascending)
return a.stackCount < b.stackCount
else
-- Normal: larger stacks first (use invertedCount)
return a.invertedCount < b.invertedCount
end
end
-- Final stable sort: preserve original collection order for identical items
-- This prevents unnecessary reshuffling when items are already sorted
return a.sequence < b.sequence
end)
return items
end
--===========================================================================
-- PHASE 5: Empty Slot Management & Apply Sort
--===========================================================================
local function CollectItems(bagIDs)
local items = {}
local sequence = 0 -- Add sequence number for stable sort
for _, bagID in ipairs(bagIDs) do
local numSlots = addon.Modules.Utils:GetBagSlotCount(bagID)
if addon.Modules.Utils:IsBagValid(bagID) then
for slot = 1, numSlots do
-- Scan directly from game API instead of cached data
local texture, itemCount, locked = GetContainerItemInfo(bagID, slot)
local itemLink = GetContainerItemLink(bagID, slot)
if itemLink then
-- Get fresh item info with ALL return values
local itemID = GetItemID(itemLink)
local name, link, quality, iLevel, category, itemType, stackCount, subType, iconTex, equipLoc, sellPrice = GetItemInfo(itemID)
-- Fallback for quality if GetItemInfo fails (ensures stability)
if not quality then
quality = GetQualityFromLink(itemLink)
end
sequence = sequence + 1
table.insert(items, {
bagID = bagID,
slot = slot,
sequence = sequence, -- Preserve original order
data = {
link = itemLink,
texture = texture or iconTex,
count = itemCount or 1,
quality = quality or 0,
name = name,
iLevel = iLevel,
type = itemType,
class = category,
subclass = subType,
equipLoc = equipLoc,
stackSize = stackCount or 1,
locked = locked,
},
quality = quality or 0,
name = name or "",
class = category or "",
})
end
end
end
end
return items
end
local function BuildTargetPositions(bagIDs, itemCount)
local positions = {}
local index = 1
-- Sort bags by priority (descending), then by bag ID (ascending)
local sortedBags = {}
for _, bagID in ipairs(bagIDs) do
table.insert(sortedBags, {
bagID = bagID,
priority = tonumber(addon.Modules.Utils:GetContainerPriority(bagID)) or 0
})
end
-- Only sort if we have more than one element
if table.getn(sortedBags) > 1 then
table.sort(sortedBags, function(a, b)
if not a then return false end
if not b then return true end
if (a.priority or 0) ~= (b.priority or 0) then
return (a.priority or 0) > (b.priority or 0)
end
return (a.bagID or 0) < (b.bagID or 0)
end)
end
-- Build positions in priority order
for _, bagInfo in ipairs(sortedBags) do
local bagID = bagInfo.bagID
local numSlots = addon.Modules.Utils:GetBagSlotCount(bagID)
if addon.Modules.Utils:IsBagValid(bagID) then
for slot = 1, numSlots do
if index <= itemCount then
positions[index] = {bag = bagID, slot = slot}
index = index + 1
else
break
end
end
end
if index > itemCount then
break
end
end
return positions
end
local function ApplySort(bagIDs, items, targetPositions)
ClearCursor()
-- Clear any pending event flags before sorting
-- (Event pending check is for manual transfers, not sorting)
addon.Modules.BagScanner:ClearEventPending()
if addon.Modules.BankScanner:IsBankOpen() then
addon.Modules.BankScanner:ClearEventPending()
end
local moveToEmpty = {}
local swapOccupied = {}
-- Use bank-specific move limit (bank has 50% more slots)
local maxMoves = (currentSortType == "bank") and MAX_BANK_MOVES_PER_CYCLE or MAX_MOVES_PER_CYCLE
-- Build move queues
for i, item in ipairs(items) do
local target = targetPositions[i]
if target then
local sourceBag, sourceSlot = item.bagID, item.slot
local targetBag, targetSlot = target.bag, target.slot
-- Skip if already in correct position
if sourceBag ~= targetBag or sourceSlot ~= targetSlot then
local targetItem = GetContainerItemLink(targetBag, targetSlot)
if not targetItem then
table.insert(moveToEmpty, {
sourceBag = sourceBag,
sourceSlot = sourceSlot,
targetBag = targetBag,
targetSlot = targetSlot,
})
else
-- Skip swap if both slots contain identical items (same ID and stack count)
local sourceLink = GetContainerItemLink(sourceBag, sourceSlot)
local sourceID = GetItemIDFromLink(sourceLink)
local targetID = GetItemIDFromLink(targetItem)
local _, sourceCount = GetContainerItemInfo(sourceBag, sourceSlot)
local _, targetCount = GetContainerItemInfo(targetBag, targetSlot)
if sourceID and targetID and sourceID == targetID and (sourceCount or 1) == (targetCount or 1) then
-- Identical items, no visual change from swapping
addon:DebugSort("Swap skipped: identical items (%s) at (%d:%d) and (%d:%d)", sourceID, sourceBag, sourceSlot, targetBag, targetSlot)
else
table.insert(swapOccupied, {
sourceBag = sourceBag,
sourceSlot = sourceSlot,
targetBag = targetBag,
targetSlot = targetSlot,
})
end
end
end
end
end
-- Execute moves to empty slots first (with limit per cycle)
local moveCount = 0
local lockedCount = 0
for _, move in ipairs(moveToEmpty) do
-- Limit moves per cycle to prevent slot locking (like Baganator)
if moveCount >= maxMoves then
break
end
-- Validate bag family constraint before moving
local sourceLink = GetContainerItemLink(move.sourceBag, move.sourceSlot)
if sourceLink and not CanItemGoInBag(sourceLink, move.targetBag) then
addon:DebugSort("Move skipped: item can't go in bag %d", move.targetBag)
elseif not sourceLink then
-- Source slot is now empty, skip
else
local _, _, locked = GetContainerItemInfo(move.sourceBag, move.sourceSlot)
if not locked then
PickupContainerItem(move.sourceBag, move.sourceSlot)
PickupContainerItem(move.targetBag, move.targetSlot)
ClearCursor()
AddPendingLock(move.sourceBag, move.sourceSlot)
AddPendingLock(move.targetBag, move.targetSlot)
moveCount = moveCount + 1
else
lockedCount = lockedCount + 1
end
end
end
-- Execute swaps with occupied slots (if we haven't hit the limit)
-- Be conservative with swaps - they can cause chain reactions
-- Limit swaps to half of max moves to leave room for next pass corrections
local maxSwaps = math.floor(maxMoves / 2)
local swapCount = 0
for _, move in ipairs(swapOccupied) do
-- Limit total moves and swaps separately
if moveCount >= maxMoves or swapCount >= maxSwaps then
break
end
-- Check swap deduplication: skip if this pair was swapped in the previous pass (oscillation)
local swapKey = SwapKey(move.sourceBag, move.sourceSlot, move.targetBag, move.targetSlot)
if previousPassSwaps[swapKey] then
addon:DebugSort("Swap dedup: skipping oscillating pair (%d:%d) <-> (%d:%d)", move.sourceBag, move.sourceSlot, move.targetBag, move.targetSlot)
else
-- Verify source item is still there (previous swaps may have moved it)
local sourceLink = GetContainerItemLink(move.sourceBag, move.sourceSlot)
if not sourceLink then
-- Source slot is now empty, skip this swap (will be handled next pass)
addon:DebugSort("Swap skipped: source slot now empty (%d:%d)", move.sourceBag, move.sourceSlot)
else
-- Validate bag family constraints for both directions
local targetLink = GetContainerItemLink(move.targetBag, move.targetSlot)
if not CanItemGoInBag(sourceLink, move.targetBag) or (targetLink and not CanItemGoInBag(targetLink, move.sourceBag)) then
addon:DebugSort("Swap skipped: bag constraint violation (%d:%d) <-> (%d:%d)", move.sourceBag, move.sourceSlot, move.targetBag, move.targetSlot)
else
local _, _, sourceLocked = GetContainerItemInfo(move.sourceBag, move.sourceSlot)
local _, _, targetLocked = GetContainerItemInfo(move.targetBag, move.targetSlot)
if not sourceLocked and not targetLocked then
PickupContainerItem(move.sourceBag, move.sourceSlot)
PickupContainerItem(move.targetBag, move.targetSlot)
ClearCursor()
AddPendingLock(move.sourceBag, move.sourceSlot)
AddPendingLock(move.targetBag, move.targetSlot)
moveCount = moveCount + 1
swapCount = swapCount + 1
currentPassSwaps[swapKey] = true
else
lockedCount = lockedCount + 1
end
end
end
end
end
-- Debug: report locked items if any
if lockedCount > 0 then
addon:DebugSort("ApplySort: %d moves completed, %d items were locked", moveCount, lockedCount)
end
return moveCount
end
--===========================================================================
-- GREY ITEM HANDLING HELPERS
--===========================================================================
-- Build tail positions (end-to-start) for a given count across the provided bags,
-- starting from the "last" regular bag (lowest priority, then highest bagID),
-- and spilling into previous bags when needed.
local function BuildGreyTailPositions(bagIDs, greyCount)
local positions = {}
if greyCount <= 0 then return positions end
-- Order bags: lowest priority first (these are considered "last"),
-- and for stable, pick higher bagID later within same priority.
local ordered = {}
for _, bagID in ipairs(bagIDs) do
-- ONLY include bags that are valid and have slots
if addon.Modules.Utils:IsBagValid(bagID) then
local numSlots = addon.Modules.Utils:GetBagSlotCount(bagID) or 0
if numSlots > 0 then
table.insert(ordered, {
bagID = bagID,
priority = tonumber(addon.Modules.Utils:GetContainerPriority(bagID)) or 0,
numSlots = numSlots,
})
end
end
end
-- Only sort if we have more than one element
if table.getn(ordered) > 1 then
table.sort(ordered, function(a, b)
if not a then return false end
if not b then return true end
if (a.priority or 0) ~= (b.priority or 0) then
return (a.priority or 0) < (b.priority or 0) -- lowest first
end
return (a.bagID or 0) > (b.bagID or 0) -- higher bagID later (treated as further to the right)
end)
end
-- Collect tail slots from end to start, spilling to previous bags as needed.
local tailSlots = {}
for _, info in ipairs(ordered) do
for slot = info.numSlots, 1, -1 do
if table.getn(tailSlots) < greyCount then
table.insert(tailSlots, { bag = info.bagID, slot = slot })
else
break
end
end
if table.getn(tailSlots) >= greyCount then break end
end
-- STABILITY FIX: Sort the collected tail slots to match the ascending scan order.
-- This ensures that identical items don't swap places every pass.
-- Ascending order: Priority DESC, BagID ASC, Slot ASC (matching BuildTargetPositions)
if table.getn(tailSlots) > 1 then
table.sort(tailSlots, function(a, b)
if not a then return false end
if not b then return true end
local aPrio = tonumber(addon.Modules.Utils:GetContainerPriority(a.bag)) or 0
local bPrio = tonumber(addon.Modules.Utils:GetContainerPriority(b.bag)) or 0
if aPrio ~= bPrio then
return aPrio > bPrio
end
if (a.bag or 0) ~= (b.bag or 0) then
return (a.bag or 0) < (b.bag or 0)
end
return (a.slot or 0) < (b.slot or 0)
end)
end
return tailSlots
end
-- Split a list of collected items into non-junk and junk items
-- Junk includes: gray items (quality 0), gray tooltip items, white equippable items (quality 1 Weapon/Armor)
-- Uses centralized ItemDetection module
local function SplitGreyItems(items)
local nonGreys, greys = {}, {}
for _, item in ipairs(items) do
local isJunk = false
if addon.Modules.ItemDetection and item.data then
local props = addon.Modules.ItemDetection:GetItemProperties(item.data, item.bagID, item.slot)
isJunk = props.isJunk
end
if isJunk then
table.insert(greys, item)
else
table.insert(nonGreys, item)
end
end
return nonGreys, greys
end
-- Rename for clarity and remove the helper function
function SortEngine:AnalyzeBags()
return self:AnalyzeContainer(addon.Constants.BAGS, "bags")
end
function SortEngine:AnalyzeBank()
if not addon.Modules.BankScanner:IsBankOpen() then
return {passes = 0, itemsOutOfPlace = 0, totalItems = 0, alreadySorted = true}
end
return self:AnalyzeContainer(addon.Constants.BANK_BAGS, "bank")
end
-- Unified analysis function that matches the actual sort logic
function SortEngine:AnalyzeContainer(bagIDs, containerType)
-- Detect specialized bags (same as SortBags)
local containers = DetectSpecializedBags(bagIDs)
local totalOutOfPlace = 0
local totalItems = 0
-- Analyze specialized bags separately
for _, bagType in ipairs({"enchant", "herb", "soul", "quiver", "ammo"}) do
local specialBags = containers[bagType]
for _, bagID in ipairs(specialBags) do
local items = CollectItems({bagID})
if table.getn(items) > 0 then
local sortedItems = SortItems(items)
local targetPositions = BuildTargetPositions({bagID}, table.getn(items))
for i, item in ipairs(sortedItems) do
local target = targetPositions[i]
if target and (item.bagID ~= target.bag or item.slot ~= target.slot) then
totalOutOfPlace = totalOutOfPlace + 1
end
end
totalItems = totalItems + table.getn(items)
end
end
end
-- Analyze regular bags with two-phase logic (matches SortBags)
local regularBagIDs = containers.regular
-- FILTER OUT EMPTY/INVALID BAGS for analysis
local validRegularBags = {}
for _, bagID in ipairs(regularBagIDs) do
if addon.Modules.Utils:IsBagValid(bagID) and addon.Modules.Utils:GetBagSlotCount(bagID) > 0 then
table.insert(validRegularBags, bagID)
end
end
if table.getn(validRegularBags) > 0 then
local allItems = CollectItems(validRegularBags)
totalItems = totalItems + table.getn(allItems)
-- Split greys/non-greys like SortBags does
local nonGreys, greys = SplitGreyItems(allItems)
-- Check non-grey positioning (should be in front positions)
if table.getn(nonGreys) > 0 then
local sortedNonGreys = SortItems(nonGreys)
local frontPositions = BuildTargetPositions(validRegularBags, table.getn(sortedNonGreys))
for i, item in ipairs(sortedNonGreys) do
local target = frontPositions[i]
if target and (item.bagID ~= target.bag or item.slot ~= target.slot) then
totalOutOfPlace = totalOutOfPlace + 1
end
end
end
-- Check grey positioning (should be in tail positions)
if table.getn(greys) > 0 then
-- CRITICAL FIX: Only use bags that actually exist and have slots
local tailPositions = BuildGreyTailPositions(validRegularBags, table.getn(greys))
for i, item in ipairs(greys) do
local target = tailPositions[i]
-- Only count as out of place if it doesn't match the specific stable target
if target and (item.bagID ~= target.bag or item.slot ~= target.slot) then
totalOutOfPlace = totalOutOfPlace + 1
end
end
end
end
if totalOutOfPlace == 0 then
return {passes = 0, itemsOutOfPlace = 0, totalItems = totalItems, alreadySorted = true}
end
-- Estimate passes (aligned to single-pass executor; cap at 6)
local displacementRatio = totalOutOfPlace / math.max(1, totalItems)
local estimatedPasses
if displacementRatio < 0.10 then
estimatedPasses = 1
elseif displacementRatio < 0.30 then
estimatedPasses = 2
elseif displacementRatio < 0.50 then
estimatedPasses = 3
elseif displacementRatio < 0.70 then
estimatedPasses = 4
elseif displacementRatio < 0.85 then
estimatedPasses = 5
else
estimatedPasses = 6
end
return {
passes = estimatedPasses,
itemsOutOfPlace = totalOutOfPlace,
totalItems = totalItems,
alreadySorted = false
}
end
-- Execute exactly ONE full sorting pass over bags (used by safety wrapper)
function SortEngine:SortBagsPass()
local bagIDs = addon.Constants.BAGS
-- Phase 1: Detect specialized bags
local containers = DetectSpecializedBags(bagIDs)
-- Phase 2: Route specialized items to their bags
local routeCount = RouteSpecializedItems(bagIDs, containers)
-- Phase 3: Consolidate stacks in ALL bags (including specialized)
local consolidateCount = ConsolidateStacks(bagIDs)
-- Phase 4: Sort items WITHIN each specialized bag (enchant, herb, soul, quiver, ammo)
local specializedMoves = 0
for _, bagType in ipairs({"enchant", "herb", "soul", "quiver", "ammo"}) do
local specialBags = containers[bagType]
for _, bagID in ipairs(specialBags) do
local items = CollectItems({bagID})
if table.getn(items) > 0 then
items = SortItems(items)
local targetPositions = BuildTargetPositions({bagID}, table.getn(items))
local moveCount = ApplySort({bagID}, items, targetPositions)
specializedMoves = specializedMoves + moveCount
end
end
end
-- Phase 5: Combined sort for regular bags — single ApplySort call
-- Non-greys go to front positions, greys go to tail positions.
-- Using a single call avoids within-pass lock conflicts where the first
-- ApplySort locks slots that the second one needs.
local regularMoves = 0
local regularBagIDs = containers.regular
if table.getn(regularBagIDs) > 0 then
local allItems = CollectItems(regularBagIDs)
local nonGreys, greys = SplitGreyItems(allItems)
local combinedItems = {}
local combinedPositions = {}
-- Non-greys: sorted to front positions
if table.getn(nonGreys) > 0 then
local sortedNonGreys = SortItems(nonGreys)
local frontPositions = BuildTargetPositions(regularBagIDs, table.getn(sortedNonGreys))
for i, item in ipairs(sortedNonGreys) do
table.insert(combinedItems, item)
table.insert(combinedPositions, frontPositions[i])
end
end
-- Greys: to tail positions (end->start)
if table.getn(greys) > 0 then
local tailPositions = BuildGreyTailPositions(regularBagIDs, table.getn(greys))
for i, item in ipairs(greys) do
table.insert(combinedItems, item)
table.insert(combinedPositions, tailPositions[i])
end
end
if table.getn(combinedItems) > 0 then
regularMoves = ApplySort(regularBagIDs, combinedItems, combinedPositions) or 0
end
end
return routeCount + consolidateCount + specializedMoves + regularMoves
end
function SortEngine:SortBags()
-- Clear property cache to ensure fresh detection
propertyCache = {}
local bagIDs = addon.Constants.BAGS
-- Phase 1: Detect specialized bags
local containers = DetectSpecializedBags(bagIDs)
-- Phase 2: Route specialized items to their bags
local routeCount = RouteSpecializedItems(bagIDs, containers)
-- Phase 3: Consolidate stacks in ALL bags (including specialized)
local consolidateCount = ConsolidateStacks(bagIDs)
-- Phase 4: Sort items WITHIN each specialized bag (enchant, herb, soul, quiver, ammo)
local specializedMoves = 0
for _, bagType in ipairs({"enchant", "herb", "soul", "quiver", "ammo"}) do
local specialBags = containers[bagType]
for _, bagID in ipairs(specialBags) do
-- Sort items within this single specialized bag
local items = CollectItems({bagID})
if table.getn(items) > 0 then
items = SortItems(items)
local targetPositions = BuildTargetPositions({bagID}, table.getn(items))
local moveCount = ApplySort({bagID}, items, targetPositions)
specializedMoves = specializedMoves + moveCount
end
end
end
-- Phase 5: Combined sort for regular bags (multi-pass, single ApplySort per pass)
local regularMoves = 0
local regularBagIDs = containers.regular
if table.getn(regularBagIDs) > 0 then
local maxPasses = 6
for pass = 1, maxPasses do
local allItems = CollectItems(regularBagIDs)
local nonGreys, greys = SplitGreyItems(allItems)
local combinedItems = {}
local combinedPositions = {}
if table.getn(nonGreys) > 0 then
local sortedNonGreys = SortItems(nonGreys)
local frontPositions = BuildTargetPositions(regularBagIDs, table.getn(sortedNonGreys))
for i, item in ipairs(sortedNonGreys) do
table.insert(combinedItems, item)
table.insert(combinedPositions, frontPositions[i])
end
end
if table.getn(greys) > 0 then
local tailPositions = BuildGreyTailPositions(regularBagIDs, table.getn(greys))
for i, item in ipairs(greys) do
table.insert(combinedItems, item)
table.insert(combinedPositions, tailPositions[i])
end
end
local passMoves = 0
if table.getn(combinedItems) > 0 then
passMoves = ApplySort(regularBagIDs, combinedItems, combinedPositions) or 0
end
regularMoves = regularMoves + passMoves
if passMoves == 0 then break end
end
end
-- Clear bag position cache after sorting to ensure fresh slot data on next UI update
-- NOTE: Don't clear ItemDetection cache - item properties don't change when items move
addon.Modules.BagScanner:ClearCache()
-- Return total moves made
return routeCount + consolidateCount + specializedMoves + regularMoves
end
-- Execute exactly ONE full sorting pass over bank (used by safety wrapper)
function SortEngine:SortBankPass()
-- Clear property cache to ensure fresh detection
propertyCache = {}
if not addon.Modules.BankScanner:IsBankOpen() then
addon:Print("Bank must be open to sort!")
return 0
end
local bagIDs = addon.Constants.BANK_BAGS
-- Phase 1: Detect specialized bags
local containers = DetectSpecializedBags(bagIDs)
-- Phase 2: Route specialized items
local routeCount = RouteSpecializedItems(bagIDs, containers)
-- Phase 3: Consolidate stacks
local consolidateCount = ConsolidateStacks(bagIDs)
-- Phase 4: Sort items WITHIN each specialized bag (single pass)
local specializedMoves = 0
for _, bagType in ipairs({"enchant", "herb", "soul", "quiver", "ammo"}) do
local specialBags = containers[bagType]
for _, bagID in ipairs(specialBags) do
local items = CollectItems({bagID})
if table.getn(items) > 0 then
items = SortItems(items)
local targetPositions = BuildTargetPositions({bagID}, table.getn(items))
local moved = ApplySort({bagID}, items, targetPositions)
specializedMoves = specializedMoves + moved
end
end
end
-- Phase 5: Regular bank bags — single combined ApplySort
local regularBagIDs = containers.regular
local regularMoves = 0
if table.getn(regularBagIDs) > 0 then
local allItems = CollectItems(regularBagIDs)
local nonGreys, greys = SplitGreyItems(allItems)
local combinedItems = {}
local combinedPositions = {}
if table.getn(nonGreys) > 0 then
local sortedNonGreys = SortItems(nonGreys)
local frontPositions = BuildTargetPositions(regularBagIDs, table.getn(sortedNonGreys))
for i, item in ipairs(sortedNonGreys) do
table.insert(combinedItems, item)
table.insert(combinedPositions, frontPositions[i])
end
end
if table.getn(greys) > 0 then
local tailPositions = BuildGreyTailPositions(regularBagIDs, table.getn(greys))
for i, item in ipairs(greys) do
table.insert(combinedItems, item)
table.insert(combinedPositions, tailPositions[i])
end
end
if table.getn(combinedItems) > 0 then
regularMoves = ApplySort(regularBagIDs, combinedItems, combinedPositions) or 0
end
end
return routeCount + consolidateCount + specializedMoves + regularMoves
end
function SortEngine:SortBank()
if not addon.Modules.BankScanner:IsBankOpen() then
addon:Print("Bank must be open to sort!")
return 0
end
local bagIDs = addon.Constants.BANK_BAGS
-- Phase 1: Detect specialized bags
local containers = DetectSpecializedBags(bagIDs)
-- Phase 2: Route specialized items
local routeCount = RouteSpecializedItems(bagIDs, containers)
-- Phase 3: Consolidate stacks
local consolidateCount = ConsolidateStacks(bagIDs)
-- Phase 4: Sort items WITHIN each specialized bag (multi-pass to avoid mid-bag holes)
local specializedMoves = 0
for _, bagType in ipairs({"enchant", "herb", "soul", "quiver", "ammo"}) do
local specialBags = containers[bagType]
for _, bagID in ipairs(specialBags) do
local maxPasses = 4
for pass = 1, maxPasses do
local items = CollectItems({bagID})
if table.getn(items) == 0 then break end
items = SortItems(items)
local targetPositions = BuildTargetPositions({bagID}, table.getn(items))
local moved = ApplySort({bagID}, items, targetPositions)
specializedMoves = specializedMoves + moved
if moved == 0 then break end
end
end
end
-- Phase 5: Regular bank bags — combined sort (single ApplySort per pass)
local regularBagIDs = containers.regular
local regularMoves = 0
if table.getn(regularBagIDs) > 0 then
local maxPasses = 6
for pass = 1, maxPasses do
local allItems = CollectItems(regularBagIDs)
local nonGreys, greys = SplitGreyItems(allItems)
local combinedItems = {}
local combinedPositions = {}
if table.getn(nonGreys) > 0 then
local sortedNonGreys = SortItems(nonGreys)
local frontPositions = BuildTargetPositions(regularBagIDs, table.getn(sortedNonGreys))
for i, item in ipairs(sortedNonGreys) do
table.insert(combinedItems, item)
table.insert(combinedPositions, frontPositions[i])
end
end
if table.getn(greys) > 0 then
local tailPositions = BuildGreyTailPositions(regularBagIDs, table.getn(greys))
for i, item in ipairs(greys) do
table.insert(combinedItems, item)
table.insert(combinedPositions, tailPositions[i])
end
end
local passMoves = 0
if table.getn(combinedItems) > 0 then
passMoves = ApplySort(regularBagIDs, combinedItems, combinedPositions) or 0
end
regularMoves = regularMoves + passMoves
if passMoves == 0 then break end
end
end
-- Clear bag position cache after sorting to ensure fresh slot data on next UI update
-- NOTE: Don't clear ItemDetection cache - item properties don't change when items move
addon.Modules.BankScanner:ClearCache()
-- Return total moves made
return routeCount + consolidateCount + specializedMoves + regularMoves
end
--===========================================================================
-- Reusable Sort Execution with Smart Pass Management
--===========================================================================
function SortEngine:ExecuteSort(sortFunction, analyzeFunction, updateFrame, sortType)
-- Check if sorting is already in progress
if self.sortingInProgress then
addon:Print("Sorting already in progress, please wait...")
return false, "sorting in progress"
end
-- Set current sort context so ApplySort uses correct move limits
currentSortType = sortType or "bags"
-- Clear property cache and swap dedup tracking at the start of a sort session
self:ClearCache()
previousPassSwaps = {}
currentPassSwaps = {}
-- Analyze to determine how many passes are needed
local analysis = analyzeFunction()
-- Check if already sorted
if analysis.alreadySorted then
currentSortType = "bags" -- Reset to default
return false, "already sorted"
end
-- Set sorting flag and update button appearance
self.sortingInProgress = true
self:UpdateSortButtonState(true)
-- Print analysis results (only when debug sort is enabled)
addon:DebugSort("Sorting %s... (%d/%d items need sorting, estimated %d passes)",
sortType, analysis.itemsOutOfPlace, analysis.totalItems, analysis.passes)
local passCount = 0
local maxPasses = math.max(analysis.passes, 1) -- Use estimated passes, minimum 1
-- With smaller batch sizes (10 moves/pass), more passes are needed.
local safetyLimit = (sortType == "bank") and math.max(maxPasses * 5, 20) or math.max(maxPasses * 4, 15)
local totalMoves = 0
local noProgressPasses = 0
local lastItemsOutOfPlace = analysis.itemsOutOfPlace
local stuckPasses = 0
-- Mute sound effects during sort to prevent rapid-fire pickup sounds
-- Try Sound_EnableSFX first (modern), then Sound_EnableAllSound (vanilla 1.12.1)
local sfxCVar = nil
local originalSFX = nil
for _, cvar in ipairs({"Sound_EnableSFX", "Sound_EnableAllSound", "EnableAllSound"}) do
local ok, val = pcall(GetCVar, cvar)
if ok and val ~= nil then
sfxCVar = cvar
originalSFX = val
pcall(SetCVar, cvar, "0")
break
end
end
addon:DebugSort("Starting %s sort (estimated: %d passes, safety limit: %d)", sortType, maxPasses, safetyLimit)
local function FinishSort()
if sfxCVar then pcall(SetCVar, sfxCVar, originalSFX) end
-- Clean up lock tracking state
ClearPendingLocks()
waitingForLocks = false
onLocksCleared = nil
ClearCursor()
SortEngine.sortingInProgress = false
SortEngine:UpdateSortButtonState(false)
currentSortType = "bags"
if sortType == "bank" then
addon.Modules.BankScanner:ClearCache()
else
addon.Modules.BagScanner:ClearCache()
end
updateFrame()
-- Refresh bars that were suppressed during sorting
if addon.Modules.QuestItemBar then addon.Modules.QuestItemBar:Update() end
if addon.Modules.TrackedItemBar then addon.Modules.TrackedItemBar:Update() end
end
local function DoSortPass()
passCount = passCount + 1
-- Clear pending lock tracking from previous pass
ClearPendingLocks()
-- Rotate swap tracking: previous pass swaps become the block list, current pass starts fresh
previousPassSwaps = currentPassSwaps
currentPassSwaps = {}
-- Perform one sort pass
local moveCount = sortFunction()
totalMoves = totalMoves + moveCount
-- Check if sorting is complete by re-analyzing
local currentAnalysis = analyzeFunction()
if currentAnalysis.alreadySorted then
-- Sorting is complete!
addon:DebugSort("%s sort complete! (%d passes, %d total moves)", sortType, passCount, totalMoves)
-- Final update (cache clearing happens after delay, uses pooled timer)
WaitForLocksCleared(FinishSort, 1.0)
elseif passCount >= safetyLimit then
-- Hit safety limit but not fully sorted
addon:DebugSort("%s sort stopped at safety limit! (%d/%d items still need sorting after %d passes)",
sortType, currentAnalysis.itemsOutOfPlace, currentAnalysis.totalItems, passCount)
-- Final update (cache clearing happens after delay, uses pooled timer)
WaitForLocksCleared(FinishSort, 1.0)
else
-- No progress guard: stop if we make no moves repeatedly
if moveCount == 0 then
noProgressPasses = noProgressPasses + 1
else
noProgressPasses = 0
end
if noProgressPasses >= 5 then
addon:DebugSort("%s sort stopped due to no progress after %d passes (items remaining: %d/%d)",
sortType, passCount, currentAnalysis.itemsOutOfPlace, currentAnalysis.totalItems)
-- Final update (cache clearing happens after delay, uses pooled timer)
WaitForLocksCleared(FinishSort, 1.0)
return
end
-- Stuck detection: abort if items out of place aren't decreasing despite moves.
-- With event-driven lock waiting, swaps can temporarily displace items,
-- so allow more passes before declaring stuck.
if moveCount > 0 and currentAnalysis.itemsOutOfPlace >= lastItemsOutOfPlace then
stuckPasses = stuckPasses + 1
else
stuckPasses = 0
end
lastItemsOutOfPlace = currentAnalysis.itemsOutOfPlace
if stuckPasses >= 3 then
addon:DebugSort("%s sort stuck: items not decreasing after %d passes with moves (remaining: %d/%d)",
sortType, stuckPasses, currentAnalysis.itemsOutOfPlace, currentAnalysis.totalItems)
WaitForLocksCleared(FinishSort, 1.0)
return
end
-- More sorting needed
local remainingRatio = currentAnalysis.itemsOutOfPlace / math.max(1, currentAnalysis.totalItems)
addon:DebugSort("%s Pass %d: %d moves, %d/%d items remaining (%.1f%%)",
sortType, passCount, moveCount, currentAnalysis.itemsOutOfPlace, currentAnalysis.totalItems, remainingRatio * 100)
-- EVENT-DRIVEN WAIT: Wait for ITEM_LOCK_CHANGED events to confirm
-- the server has processed all moves before starting next pass.
-- Falls back to timer if locks don't clear within timeout.
local fallbackTimeout = (sortType == "bank") and 3.0 or 2.5
addon:DebugSort("Waiting for item locks to clear before next pass (timeout: %.1fs)...", fallbackTimeout)
-- Wait for locks to clear via ITEM_LOCK_CHANGED events, then sort again
WaitForLocksCleared(DoSortPass, fallbackTimeout)
end
end
-- Hard timeout safety net: force-reset sortingInProgress after max reasonable time
-- With 10 moves/pass, ~0.5s min delay + up to 2.5s lock wait per pass
local hardTimeout = (sortType == "bank") and 45 or 35
Guda_ScheduleTimer(hardTimeout, function()
if SortEngine.sortingInProgress then
addon:DebugSort("%s sort force-stopped after %ds timeout", sortType, hardTimeout)
FinishSort()
end
end)
-- Start the first pass
DoSortPass()
return true, "sorting started"
end