From 8d128bd3ebcdedeca36207d33d9965395178550b Mon Sep 17 00:00:00 2001 From: Vati Date: Thu, 26 Feb 2026 21:07:19 +0400 Subject: [PATCH] imp: bag sorting --- Sorting/SortEngine.lua | 291 +++++++++++++++++++++++++++++++---------- 1 file changed, 224 insertions(+), 67 deletions(-) diff --git a/Sorting/SortEngine.lua b/Sorting/SortEngine.lua index e979ba3..0915610 100644 --- a/Sorting/SortEngine.lua +++ b/Sorting/SortEngine.lua @@ -19,6 +19,20 @@ local MAX_BANK_MOVES_PER_CYCLE = 25 -- 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 @@ -111,6 +125,44 @@ local CATEGORY_ORDER = { ["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, @@ -136,6 +188,29 @@ local GEM_PATTERNS = { -- 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 = {} @@ -768,6 +843,15 @@ local function AddSortKeys(items) 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 @@ -847,7 +931,13 @@ local function SortItems(items) if a.equipSlotOrder ~= b.equipSlotOrder then return a.equipSlotOrder < b.equipSlotOrder end - -- Group by itemSubType first (e.g., "Ring", "Staff", "Cloth", etc.) + -- 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 @@ -1093,12 +1183,23 @@ local function ApplySort(bagIDs, items, targetPositions) targetSlot = targetSlot, }) else - table.insert(swapOccupied, { - sourceBag = sourceBag, - sourceSlot = sourceSlot, - targetBag = targetBag, - targetSlot = targetSlot, - }) + -- 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 @@ -1114,14 +1215,22 @@ local function ApplySort(bagIDs, items, targetPositions) break end - local _, _, locked = GetContainerItemInfo(move.sourceBag, move.sourceSlot) - if not locked then - PickupContainerItem(move.sourceBag, move.sourceSlot) - PickupContainerItem(move.targetBag, move.targetSlot) - ClearCursor() - moveCount = moveCount + 1 + -- 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 - lockedCount = lockedCount + 1 + local _, _, locked = GetContainerItemInfo(move.sourceBag, move.sourceSlot) + if not locked then + PickupContainerItem(move.sourceBag, move.sourceSlot) + PickupContainerItem(move.targetBag, move.targetSlot) + ClearCursor() + moveCount = moveCount + 1 + else + lockedCount = lockedCount + 1 + end end end @@ -1137,23 +1246,36 @@ local function ApplySort(bagIDs, items, targetPositions) break end - -- 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) + -- 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 - 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() - moveCount = moveCount + 1 - swapCount = swapCount + 1 + -- 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 - lockedCount = lockedCount + 1 + -- 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() + moveCount = moveCount + 1 + swapCount = swapCount + 1 + currentPassSwaps[swapKey] = true + else + lockedCount = lockedCount + 1 + end + end end end end @@ -1243,44 +1365,14 @@ 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 when available +-- Uses centralized ItemDetection module local function SplitGreyItems(items) local nonGreys, greys = {}, {} for _, item in ipairs(items) do local isJunk = false - - -- Use centralized ItemDetection when available if addon.Modules.ItemDetection and item.data then local props = addon.Modules.ItemDetection:GetItemProperties(item.data, item.bagID, item.slot) isJunk = props.isJunk - else - -- Fallback: manual junk detection - local quality = tonumber(item.quality or 0) - local isGray = quality == 0 or IsItemGrayTooltip(item.bagID, item.slot, item.data and item.data.link) - local itemClass = item.class or "" - local itemSubclass = item.data and item.data.subclass or "" - local itemLink = item.data and item.data.link - local isWhiteEquip = false - - if quality == 1 and (itemClass == "Weapon" or itemClass == "Armor") then - local isSpecialSlot = (itemSubclass == "INVTYPE_TRINKET" or - itemSubclass == "INVTYPE_FINGER" or - itemSubclass == "INVTYPE_NECK" or - itemSubclass == "INVTYPE_TABARD" or - itemSubclass == "INVTYPE_BODY") - - if not isSpecialSlot then - local isProfTool = IsProfessionTool(itemLink, itemSubclass) - local hasSpecialText = false - if not isProfTool then - hasSpecialText = HasSpecialTooltipText(item.bagID, item.slot, itemLink) - end - if not isProfTool and not hasSpecialText then - isWhiteEquip = true - end - end - end - isJunk = isGray or isWhiteEquip end if isJunk then @@ -1410,9 +1502,6 @@ end -- Execute exactly ONE full sorting pass over bags (used by safety wrapper) function SortEngine:SortBagsPass() - -- Clear property cache to ensure fresh detection - propertyCache = {} - local bagIDs = addon.Constants.BAGS -- Phase 1: Detect specialized bags @@ -1599,9 +1688,6 @@ function SortEngine:SortBankPass() end function SortEngine:SortBank() - -- 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 @@ -1687,8 +1773,10 @@ function SortEngine:ExecuteSort(sortFunction, analyzeFunction, updateFrame, sort -- Set current sort context so ApplySort uses correct move limits currentSortType = sortType or "bags" - -- Clear property cache at the start of a sort operation + -- 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() @@ -1713,12 +1801,32 @@ function SortEngine:ExecuteSort(sortFunction, analyzeFunction, updateFrame, sort local safetyLimit = (sortType == "bank") and math.max(maxPasses * 4, 15) or math.max(maxPasses * 3, 10) 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 DoSortPass() passCount = passCount + 1 + -- 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 @@ -1732,6 +1840,7 @@ function SortEngine:ExecuteSort(sortFunction, analyzeFunction, updateFrame, sort -- Final update (cache clearing happens after delay, uses pooled timer) Guda_ScheduleTimer(0.3, function() + if sfxCVar then pcall(SetCVar, sfxCVar, originalSFX) end SortEngine.sortingInProgress = false SortEngine:UpdateSortButtonState(false) currentSortType = "bags" -- Reset sort context @@ -1750,6 +1859,7 @@ function SortEngine:ExecuteSort(sortFunction, analyzeFunction, updateFrame, sort -- Final update (cache clearing happens after delay, uses pooled timer) Guda_ScheduleTimer(0.3, function() + if sfxCVar then pcall(SetCVar, sfxCVar, originalSFX) end SortEngine.sortingInProgress = false SortEngine:UpdateSortButtonState(false) currentSortType = "bags" -- Reset sort context @@ -1775,6 +1885,7 @@ function SortEngine:ExecuteSort(sortFunction, analyzeFunction, updateFrame, sort -- Final update (cache clearing happens after delay, uses pooled timer) Guda_ScheduleTimer(0.3, function() + if sfxCVar then pcall(SetCVar, sfxCVar, originalSFX) end SortEngine.sortingInProgress = false SortEngine:UpdateSortButtonState(false) currentSortType = "bags" -- Reset sort context @@ -1789,6 +1900,33 @@ function SortEngine:ExecuteSort(sortFunction, analyzeFunction, updateFrame, sort return end + -- Stuck detection: abort if items out of place aren't decreasing despite moves + 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) + + Guda_ScheduleTimer(0.3, function() + if sfxCVar then pcall(SetCVar, sfxCVar, originalSFX) end + SortEngine.sortingInProgress = false + SortEngine:UpdateSortButtonState(false) + currentSortType = "bags" + if sortType == "bank" then + addon.Modules.BankScanner:ClearCache() + else + addon.Modules.BagScanner:ClearCache() + end + updateFrame() + end) + 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%%)", @@ -1808,6 +1946,25 @@ function SortEngine:ExecuteSort(sortFunction, analyzeFunction, updateFrame, sort end end + -- Hard timeout safety net: force-reset sortingInProgress after max reasonable time + -- Bank with 240 slots at ~0.5s per pass, 15 passes = ~8s; use 30s as generous limit + local hardTimeout = (sortType == "bank") and 30 or 20 + Guda_ScheduleTimer(hardTimeout, function() + if SortEngine.sortingInProgress then + addon:DebugSort("%s sort force-stopped after %ds timeout", sortType, hardTimeout) + if sfxCVar then pcall(SetCVar, sfxCVar, originalSFX) end + SortEngine.sortingInProgress = false + SortEngine:UpdateSortButtonState(false) + currentSortType = "bags" + if sortType == "bank" then + addon.Modules.BankScanner:ClearCache() + else + addon.Modules.BagScanner:ClearCache() + end + updateFrame() + end + end) + -- Start the first pass DoSortPass() return true, "sorting started"