Files
ShardCap/ShardCap.lua
T

630 lines
23 KiB
Lua

--[[ 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.1"
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 <n> 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
-- Older ClassicAPI builds ignore the third argument and return a fresh table.
-- Always read the returned table, and never read the buffer for an empty slot.
local slotBuffer = {}
local function SlotInfo(bag, slot)
if api.classic then
local info = C_Container.GetContainerItemInfo(bag, slot, slotBuffer)
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 moveRefused = false -- stop this pass when no request was sent
local function MoveShards(src, dst, n)
if api.classic and type(C_Container.MoveItem) == "function" then
if C_Container.MoveItem(src.bag, src.slot, dst.bag, dst.slot, n) == false then
moveRefused = true
return false
end
return true
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
return true
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
if not MoveShards(src, dst, n) then return false end
Notify("Requested merge of " .. 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 moveRefused then return false 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
if C_Container.SwapItems(s.bag, s.slot, dst.bag, dst.slot) == false then
moveRefused = true
return false
end
else
if not MoveShards(s, dst, s.count) then return false end
end
Notify("Requested move of " .. 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
if C_Container.SwapItems(r.bag, r.slot, dst.bag, dst.slot) == false then
moveRefused = true
return false
end
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("Requested move of " .. (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()
moveRefused = false
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 moveRefused then return false end
if ReagentStep(stacks, freeSoul, reagents) then return true end
if moveRefused then return false 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
-- Counts only: the display does not need sorting records or free-slot tables.
local function StatusCounts()
local shards, figurines, stones, free = 0, 0, 0, 0
for bag = 0, 4 do
local soul = IsSoulBag(bag)
for slot = 1, GetContainerNumSlots(bag) do
local id, count = SlotInfo(bag, slot)
if id == SHARD_ID then shards = shards + count
elseif id == 16583 then figurines = figurines + count
elseif id == 5565 then stones = stones + count
elseif not id and soul then free = free + 1 end
end
end
return shards, figurines, stones, free
end
local function StatusLines()
local total, figurines, stones, free = StatusCounts()
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" .. figurines .. "|r" .. BankSuffix(16583)
.. " Infernal Stones: |cffffffff" .. stones .. "|r" .. BankSuffix(5565),
"Soul bags: " .. SoulBagList() .. (db.soulbags and " (manual)" or " (auto)")
.. ", " .. free .. " 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<number>|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
}