--[[ ShardCap 2.0 -- keep your Soul Shards at a fixed cap. WoW 1.12.1 (Lua 5.0). Based on ShardCap by "The Johnnest of them all" (dogmax), with ideas from ShardCapPlus (akzkak/Paduk) and voxan-dev's fork. Optional client-mod support (auto-detected, never required): ClassicAPI - exact item IDs/stack counts, locale-free soul bag detection, cursor-free item moves, BAG_UPDATE_DELAYED batching Nampower - exact item IDs/stack counts via GetBagItem ]] local ADDON = "ShardCap" local VERSION = "2.1.0" local SHARD_ID = 6265 -- Known soul bags (vanilla): Soul Pouch, Felcloth Bag, Core Felcloth Bag, -- Small Soul Pouch, Box of Souls. Anything else is caught by the subtype check -- or ClassicAPI; /shardcap soulbag covers whatever slips through. local SOULBAG_IDS = { [21340]=true, [21341]=true, [21342]=true, [22243]=true, [22244]=true } local SOULBAG_SUBTYPES = { ["Soul Bag"]=true, ["Seelentasche"]=true } -- Warlock reagents that WoW 1.18.1 Clients let you keep in a soul bag local REAGENT_IDS = { [16583]=true, [5565]=true } -- Demonic Figurine, Infernal Stone local DEFAULTS = { cap = 12, -- shards to keep notify = false, -- chat output when something is deleted/moved sort = true, -- move shards from normal bags into free soul bag slots combat = false, -- also trim while in combat reagents = true, -- move Demonic Figurines / Infernal Stones into soul bags soulbags = nil, -- nil = auto-detect, otherwise { [bagID]=true } } local db local inCombat = false local api = {} ------------------------------------------------------------------------------ -- Output ------------------------------------------------------------------------------ local function Print(msg) DEFAULT_CHAT_FRAME:AddMessage("|cff9482c9ShardCap|r - " .. msg) end local function Notify(msg) if db and db.notify then Print(msg) end end local function Plural(n, word) if n == 1 then return n .. " " .. word end return n .. " " .. word .. "s" end ------------------------------------------------------------------------------ -- Client-mod detection ------------------------------------------------------------------------------ local function DetectAPIs() api.classic = type(C_Container) == "table" and type(C_Container.GetContainerItemInfo) == "function" api.classicSwap = api.classic and type(C_Container.SwapItems) == "function" api.classicBagType = api.classic and type(C_Container.GetContainerNumFreeSlots) == "function" api.nampower = (not api.classic) and type(GetBagItem) == "function" api.superwow = SUPERWOW_VERSION ~= nil api.unitxp = type(UnitXP) == "function" and pcall(UnitXP, "nop", "nop") or false end ------------------------------------------------------------------------------ -- Slot reading: returns itemID, stackCount, locked (or nil for empty) ------------------------------------------------------------------------------ local function SlotInfoVanilla(bag, slot) local link = GetContainerItemLink(bag, slot) if not link then return nil end -- Exact ID match. The original string.find(link, 6265) also matched any -- item whose link merely contained "6265" (e.g. item 16265) and deleted it. local _, _, id = string.find(link, "item:(%d+)") local _, count, locked = GetContainerItemInfo(bag, slot) return tonumber(id), count or 1, locked end local function SlotInfo(bag, slot) if api.classic then local info = C_Container.GetContainerItemInfo(bag, slot) if not info then return nil end return info.itemID, info.stackCount or 1, info.isLocked elseif api.nampower then local ok, t = pcall(GetBagItem, bag, slot) if not ok then api.nampower = false -- unexpected API shape; fall back for good return SlotInfoVanilla(bag, slot) end if not t then return nil end local _, _, locked = GetContainerItemInfo(bag, slot) return t.itemId, t.stackCount or 1, locked end return SlotInfoVanilla(bag, slot) end ------------------------------------------------------------------------------ -- Soul bag detection ------------------------------------------------------------------------------ local function IsBit4Set(n) -- Lua 5.0 has no bit ops in every client return math.mod(math.floor(n / 4), 2) == 1 end local function AutoIsSoulBag(bag) if bag == 0 then return false end if api.classicBagType then local _, bagType = C_Container.GetContainerNumFreeSlots(bag) if bagType and bagType > 0 then return IsBit4Set(bagType) end end local link = GetInventoryItemLink("player", ContainerIDToInventoryID(bag)) if not link then return false end local _, _, id = string.find(link, "item:(%d+)") id = tonumber(id) if not id then return false end if SOULBAG_IDS[id] then return true end local _, _, _, _, _, subType = GetItemInfo(id) return subType ~= nil and SOULBAG_SUBTYPES[subType] == true end local function IsSoulBag(bag) if db.soulbags then return db.soulbags[bag] == true end return AutoIsSoulBag(bag) end ------------------------------------------------------------------------------ -- Inventory scan ------------------------------------------------------------------------------ local function Scan() local stacks, freeSoul, total, reagents, reagentTotals = {}, {}, 0, {}, {} for bag = 0, 4 do local soul = IsSoulBag(bag) for slot = 1, GetContainerNumSlots(bag) do local id, count, locked = SlotInfo(bag, slot) if id == SHARD_ID then table.insert(stacks, { bag = bag, slot = slot, count = count, locked = locked, soul = soul }) total = total + count elseif not id and soul then table.insert(freeSoul, { bag = bag, slot = slot }) elseif id and REAGENT_IDS[id] then reagentTotals[id] = (reagentTotals[id] or 0) + count end if id and REAGENT_IDS[id] and not soul then table.insert(reagents, { id = id, bag = bag, slot = slot, count = count, locked = locked }) end end end return stacks, freeSoul, total, reagents, reagentTotals end -- Deletion order: shards outside soul bags first, backpack first, -- and from the end of each bag; soul bag shards go last. local function DeleteOrder(a, b) if a.soul ~= b.soul then return not a.soul end if a.bag ~= b.bag then return a.bag < b.bag end return a.slot > b.slot end ------------------------------------------------------------------------------ -- Actions (one per pass; the next pass runs after the server answers) ------------------------------------------------------------------------------ local function CursorBusy() return CursorHasItem() or (SpellIsTargeting and SpellIsTargeting()) end -- returns true if it did something local function TrimStep(stacks, total) local excess = total - db.cap if excess <= 0 then return false end table.sort(stacks, DeleteOrder) for _, s in ipairs(stacks) do if not s.locked then local n = s.count if n > excess then n = excess end if n < s.count then SplitContainerItem(s.bag, s.slot, n) -- delete exactly the excess else PickupContainerItem(s.bag, s.slot) end if CursorHasItem() then DeleteCursorItem() Notify("Deleted " .. Plural(n, "Soul Shard") .. " from bag " .. s.bag .. " slot " .. s.slot .. " (" .. total .. " -> " .. (total - n) .. ", cap " .. db.cap .. ").") return true end return false end end return false end -- Max shards per slot: 3 on WoW 1.18.1 Clients, 1 on Blizzlike servers. local function MaxStack() if api.classic and C_Item and type(C_Item.GetItemMaxStackSizeByID) == "function" then local ok, n = pcall(C_Item.GetItemMaxStackSizeByID, SHARD_ID) if ok and type(n) == "number" and n > 0 then return n end end local _, _, _, _, _, _, n = GetItemInfo(SHARD_ID) if type(n) == "number" and n > 0 then return n end return 1 end -- Move exactly n shards from src onto dst (dst empty or a partial shard stack). local function MoveShards(src, dst, n) if api.classic and type(C_Container.MoveItem) == "function" then C_Container.MoveItem(src.bag, src.slot, dst.bag, dst.slot, n) return end if n < src.count then SplitContainerItem(src.bag, src.slot, n) else PickupContainerItem(src.bag, src.slot) end PickupContainerItem(dst.bag, dst.slot) if CursorHasItem() then PickupContainerItem(src.bag, src.slot) end -- put it back end -- WoW 1.18.1 Clients: merge partial stacks so shards take as few slots as possible. -- Fills the most-preferred partial stack (soul bag first) from the least-preferred one. local function MergeStep(stacks, maxStack) if maxStack <= 1 then return false end local partial = {} for _, s in ipairs(stacks) do if s.count < maxStack and not s.locked then table.insert(partial, s) end end if table.getn(partial) < 2 then return false end table.sort(partial, DeleteOrder) -- first = least preferred, last = most preferred local src, dst = partial[1], partial[table.getn(partial)] local n = maxStack - dst.count if n > src.count then n = src.count end MoveShards(src, dst, n) Notify("Merged " .. Plural(n, "Soul Shard") .. " into bag " .. dst.bag .. " slot " .. dst.slot .. ".") return true end local function SortStep(stacks, freeSoul) if not db.sort then return false end if MergeStep(stacks, MaxStack()) then return true end if table.getn(freeSoul) == 0 then return false end for _, s in ipairs(stacks) do if not s.soul and not s.locked then local dst = freeSoul[1] if api.classicSwap then C_Container.SwapItems(s.bag, s.slot, dst.bag, dst.slot) else MoveShards(s, dst, s.count) end Notify("Moved " .. Plural(s.count, "Soul Shard") .. " into your soul bag (bag " .. dst.bag .. ").") return true end end return false end ------------------------------------------------------------------------------ -- Warlock reagents -> soul bag (WoW 1.18.1 Clients allow these in soul bags) ------------------------------------------------------------------------------ local rejected = {} -- [itemID] = true: this server won't put it in a soul bag local failures = {} -- [itemID] = moves the server silently refused local lastReagentMove -- { id, bag, slot } of the last move we sent local function ReagentAllowed(id) if rejected[id] then return false end if api.classic and C_Item and type(C_Item.GetItemFamily) == "function" then local ok, fam = pcall(C_Item.GetItemFamily, id) if ok and type(fam) == "number" and fam > 0 then return IsBit4Set(fam) end end -- Only servers where shards stack (WoW 1.18.1 Clients) allow this; on Blizzlike -- servers the client would refuse the move with an error every time. return MaxStack() > 1 end local function Reject(id) if rejected[id] then return end rejected[id] = true local name = GetItemInfo(id) or ("item " .. id) Print(name .. " can't go in a soul bag on this server; won't try again this session.") end -- Soul bag slots to keep free for shards: enough to hold the cap, -- and never less than the shard stacks still waiting outside. local function ShardReserve(stacks) local inSoul, outside = 0, 0 for _, s in ipairs(stacks) do if s.soul then inSoul = inSoul + 1 else outside = outside + 1 end end local need = math.ceil(db.cap / MaxStack()) - inSoul if need < outside then need = outside end if need < 0 then need = 0 end return need end local function ReagentStep(stacks, freeSoul, reagents) if not db.reagents then return false end -- did the last move silently fail (server refused, item still where it was)? local last = lastReagentMove lastReagentMove = nil if last then for _, r in ipairs(reagents) do if r.id == last.id and r.bag == last.bag and r.slot == last.slot and not r.locked then failures[r.id] = (failures[r.id] or 0) + 1 if failures[r.id] >= 2 then Reject(r.id) end end end end local free = table.getn(freeSoul) if free <= ShardReserve(stacks) then return false end for _, r in ipairs(reagents) do if not r.locked and ReagentAllowed(r.id) then local dst = freeSoul[free] -- fill from the end, leave the front for shards if api.classicSwap then C_Container.SwapItems(r.bag, r.slot, dst.bag, dst.slot) else PickupContainerItem(r.bag, r.slot) PickupContainerItem(dst.bag, dst.slot) if CursorHasItem() then -- client refused it: put it back, stop trying PickupContainerItem(r.bag, r.slot) Reject(r.id) return true end end lastReagentMove = { id = r.id, bag = r.bag, slot = r.slot } Notify("Moved " .. (GetItemInfo(r.id) or "reagent") .. " into your soul bag (bag " .. dst.bag .. ").") return true end end return false end ------------------------------------------------------------------------------ -- Scheduler (hidden OnUpdate frame: zero cost while idle) ------------------------------------------------------------------------------ local frame = CreateFrame("Frame") local nextRun, forced, passes = nil, false, 0 local MAX_PASSES = 60 local function Schedule(delay, force) local t = GetTime() + (delay or 0.3) if not nextRun or t < nextRun then nextRun = t end if force then forced = true end passes = 0 frame:Show() end local function Pass() if not db then return false end local manual = forced if (inCombat or UnitAffectingCombat("player")) and not db.combat and not manual then return false -- PLAYER_REGEN_ENABLED will reschedule end if CursorBusy() then return true end -- try again shortly local stacks, freeSoul, total, reagents = Scan() if TrimStep(stacks, total) then return true end -- don't shuffle bags mid-combat even if combat trimming is on if not (inCombat or UnitAffectingCombat("player")) then if SortStep(stacks, freeSoul) then return true end if ReagentStep(stacks, freeSoul, reagents) then return true end end -- retry while stacks are still locked by a pending server transaction for _, s in ipairs(stacks) do if s.locked then return true end end for _, r in ipairs(reagents) do if r.locked then return true end end return lastReagentMove ~= nil -- one more pass to confirm the last move landed end frame:Hide() frame:SetScript("OnUpdate", function() if not nextRun or GetTime() < nextRun then return end nextRun = nil passes = passes + 1 local again = Pass() if again and passes < MAX_PASSES then nextRun = GetTime() + 0.3 else forced = false this:Hide() end end) ------------------------------------------------------------------------------ -- Saved variables & migration ------------------------------------------------------------------------------ local function InitDB() if type(ShardCapDB) ~= "table" then ShardCapDB = {} -- migrate settings from ShardCap 1.x if type(SHARDCAP_CAP_VALUE) == "number" then ShardCapDB.cap = SHARDCAP_CAP_VALUE end if SHARDCAP_SPAM == true then ShardCapDB.notify = true end end SHARDCAP_CAP_VALUE, SHARDCAP_SPAM = nil, nil db = ShardCapDB for k, v in pairs(DEFAULTS) do if db[k] == nil then db[k] = v end end end local function WarnDuplicates() if IsAddOnLoaded("ShardCapPlus") then Print("|cffff5555ShardCapPlus is also enabled.|r Disable it - both would delete shards.") end end ------------------------------------------------------------------------------ -- Events ------------------------------------------------------------------------------ frame:RegisterEvent("ADDON_LOADED") frame:RegisterEvent("PLAYER_ENTERING_WORLD") frame:RegisterEvent("PLAYER_REGEN_DISABLED") frame:RegisterEvent("PLAYER_REGEN_ENABLED") frame:SetScript("OnEvent", function() if event == "ADDON_LOADED" then if arg1 ~= ADDON then return end InitDB() DetectAPIs() -- ClassicAPI batches all BAG_UPDATEs of a frame into one event if api.classic then frame:RegisterEvent("BAG_UPDATE_DELAYED") else frame:RegisterEvent("BAG_UPDATE") end elseif event == "PLAYER_ENTERING_WORLD" then inCombat = UnitAffectingCombat("player") and true or false WarnDuplicates() Schedule(2) elseif event == "PLAYER_REGEN_DISABLED" then inCombat = true elseif event == "PLAYER_REGEN_ENABLED" then inCombat = false Schedule(0.5) elseif event == "BAG_UPDATE" or event == "BAG_UPDATE_DELAYED" then if not inCombat or db.combat then Schedule(0.3) end end end) ------------------------------------------------------------------------------ -- Slash commands ------------------------------------------------------------------------------ local function OnOff(v) if v then return "|cff55ff55on|r" end return "|cffff5555off|r" end local function SoulBagList() local list = {} for bag = 1, 4 do if IsSoulBag(bag) then table.insert(list, bag) end end if table.getn(list) == 0 then return "none" end return table.concat(list, ", ") end local function Backend() local parts = {} if api.classic then table.insert(parts, "ClassicAPI") elseif api.nampower then table.insert(parts, "Nampower") else table.insert(parts, "vanilla API") end return table.concat(parts, ", ") end -- Items in the bank (ClassicAPI only: C_Item.GetItemCount reads the bank -- without it being open). Returns nil when unknown. local function BankCount(id) if not (api.classic and C_Item and type(C_Item.GetItemCount) == "function") then return nil end local ok1, all = pcall(C_Item.GetItemCount, id, true) local ok2, carried = pcall(C_Item.GetItemCount, id, false) if not ok1 or not ok2 or type(all) ~= "number" or type(carried) ~= "number" then return nil end if all > carried then return all - carried end return 0 end local function BankSuffix(id) local n = BankCount(id) if n and n > 0 then return " |cffaaaaaa(+" .. n .. " in bank)|r" end return "" end -- Status lines shared by /shardcap and the options panel local function StatusLines() local _, freeSoul, total, _, reagentTotals = Scan() local color = "|cffffffff" if total > db.cap then color = "|cffff9900" end -- over the cap (e.g. in combat) return "Soul Shards: " .. color .. total .. "/" .. db.cap .. "|r" .. BankSuffix(SHARD_ID), "Demonic Figurines: |cffffffff" .. (reagentTotals[16583] or 0) .. "|r" .. BankSuffix(16583) .. " Infernal Stones: |cffffffff" .. (reagentTotals[5565] or 0) .. "|r" .. BankSuffix(5565), "Soul bags: " .. SoulBagList() .. (db.soulbags and " (manual)" or " (auto)") .. ", " .. table.getn(freeSoul) .. " free soul slots" end local function PrintStatus() local shards, reagents, bags = StatusLines() Print(shards .. " (v" .. VERSION .. ")") Print(reagents) Print(bags) Print("Notify " .. OnOff(db.notify) .. ", sort " .. OnOff(db.sort) .. ", trim in combat " .. OnOff(db.combat) .. ", reagents " .. OnOff(db.reagents) .. " [" .. Backend() .. "]") end local function PrintHelp() Print("/shardcap |cffffffff|r - set the cap (0-200)") Print("/shardcap |cffffffffstatus|r - show cap, shard count and settings") Print("/shardcap |cffffffffdelete|r - trim to the cap right now (even in combat)") Print("/shardcap |cffffffffspam|r - toggle chat notifications") Print("/shardcap |cffffffffsort|r - toggle moving shards into your soul bag") Print("/shardcap |cffffffffcombat|r - toggle trimming during combat") Print("/shardcap |cffffffffreagents|r - toggle moving Demonic Figurines / Infernal Stones into the soul bag") Print("/shardcap |cffffffffsoulbag auto|1-4|r - auto-detect or toggle a bag as soul bag") Print("/shardcap |cffffffffoptions|r - open the options window (ClassicAPI)") Print("/shardcap |cffffffffreset|r - restore defaults") Print("Trims when you leave combat, deleting shards outside your soul bag first.") end local function SetSoulBag(arg) if arg == "auto" or arg == "" then db.soulbags = nil Print("Soul bag detection: auto (" .. SoulBagList() .. ").") return end local bag = tonumber(arg) if not bag or bag < 1 or bag > 4 or bag ~= math.floor(bag) then Print("Use a bag number 1-4, or 'auto'.") return end if not db.soulbags then db.soulbags = {} for b = 1, 4 do if AutoIsSoulBag(b) then db.soulbags[b] = true end end end if db.soulbags[bag] then db.soulbags[bag] = nil else db.soulbags[bag] = true end Print("Soul bags (manual): " .. SoulBagList() .. ". '/shardcap soulbag auto' to undo.") Schedule(0.3) end SLASH_SHARDCAP1 = "/shardcap" SLASH_SHARDCAP2 = "/scap" SlashCmdList["SHARDCAP"] = function(msg) if not db then return end msg = string.lower(msg or "") local _, _, cmd, rest = string.find(msg, "^%s*(%S*)%s*(.-)%s*$") cmd, rest = cmd or "", rest or "" local n = tonumber(cmd) if n then if n < 0 or n > 200 or n ~= math.floor(n) then Print("The cap must be a whole number from 0 to 200.") return end db.cap = n Print("Cap set to " .. Plural(n, "shard") .. ".") Schedule(0.3) elseif cmd == "" or cmd == "status" then PrintStatus() if cmd == "" then Print("Type |cffffffff/shardcap help|r for commands.") end elseif cmd == "help" or cmd == "info" then PrintHelp() elseif cmd == "delete" or cmd == "trim" then Schedule(0, true) elseif cmd == "spam" or cmd == "notify" then db.notify = not db.notify Print("Notifications " .. OnOff(db.notify) .. ".") elseif cmd == "sort" then db.sort = not db.sort Print("Moving shards into soul bags " .. OnOff(db.sort) .. ".") Schedule(0.3) elseif cmd == "combat" then db.combat = not db.combat Print("Trimming during combat " .. OnOff(db.combat) .. ".") elseif cmd == "reagents" then db.reagents = not db.reagents Print("Moving Demonic Figurines / Infernal Stones into the soul bag " .. OnOff(db.reagents) .. ".") Schedule(0.3) elseif cmd == "soulbag" then SetSoulBag(rest) elseif cmd == "options" or cmd == "config" or cmd == "opt" then if ShardCapAPI.OpenOptions then ShardCapAPI.OpenOptions() else Print("The options window needs ClassicAPI (AddOn Options). Use |cffffffff/shardcap help|r for commands.") end elseif cmd == "reset" then ShardCapDB = nil InitDB() Print("Settings reset. Cap is " .. db.cap .. ".") else PrintHelp() end end ------------------------------------------------------------------------------ -- Internal API for Options.lua (not a stable public interface) ------------------------------------------------------------------------------ ShardCapAPI = { VERSION = VERSION, DEFAULTS = DEFAULTS, GetDB = function() return db end, Schedule = Schedule, IsSoulBag = IsSoulBag, AutoIsSoulBag = AutoIsSoulBag, StatusLines = StatusLines, Backend = Backend, Print = Print, OpenOptions = nil, -- set by Options.lua when an options window is available }