From d9634b06d599d2014f33e748a0fe587bd4a73093 Mon Sep 17 00:00:00 2001 From: OEVG <950+oevg@noreply.octowow.st> Date: Fri, 30 Oct 2026 16:54:29 +0000 Subject: [PATCH] Apply Patch --- Options.lua | 330 +++++++++++++++++++++++++++ ShardCap.lua | 595 +++++++++++++++++++++++++++++++++++++++++++++++++ ShardCap.toc | 8 + tests/test.lua | 471 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 1404 insertions(+) create mode 100644 Options.lua create mode 100644 ShardCap.lua create mode 100644 ShardCap.toc create mode 100644 tests/test.lua diff --git a/Options.lua b/Options.lua new file mode 100644 index 0000000..57a1d75 --- /dev/null +++ b/Options.lua @@ -0,0 +1,330 @@ +--[[ ShardCap options window, shown under AddOns in ClassicAPI's AddOn Options + (Game Menu > AddOn Options, or /shardcap options). + + Uses the later-client panel protocol that ClassicAPI backports + (InterfaceOptions_AddCategory with okay / cancel / default / refresh). + Without it this file does nothing and the slash commands still work. + + Changes apply at once. Cancel (and Escape) puts back the settings you had + when the window was opened; Defaults resets the page that is shown. + Lua 5.0: no `...` expressions, no select(). +]] + +if type(InterfaceOptions_AddCategory) ~= "function" or not ShardCapAPI then return end + +local API = ShardCapAPI +local DEFAULTS = API.DEFAULTS + +local function DB() return API.GetDB() end +local function Changed() API.Schedule(0.3) end + +------------------------------------------------------------------------------ +-- Snapshot for Cancel (shared by both pages; one restore is enough) +------------------------------------------------------------------------------ +local KEYS = { "cap", "notify", "sort", "combat", "reagents" } +local saved + +local function CopyBags(t) + if not t then return nil end + local c = {} + for k, v in pairs(t) do c[k] = v end + return c +end + +local function Snapshot() + local db = DB() + if saved or not db then return end + saved = { soulbags = CopyBags(db.soulbags) } + for _, k in ipairs(KEYS) do saved[k] = db[k] end +end + +local function Restore() + local db = DB() + if not saved or not db then saved = nil return end + for _, k in ipairs(KEYS) do db[k] = saved[k] end + db.soulbags = CopyBags(saved.soulbags) + saved = nil + Changed() +end + +------------------------------------------------------------------------------ +-- Widgets (same templates ClassicAPI's own panel uses) +------------------------------------------------------------------------------ +local numControls = 0 +local function NextName() + numControls = numControls + 1 + return "ShardCapOptionsControl" .. numControls +end + +local function RefreshControls(page) + if not DB() then return end + page.updating = true + for _, control in ipairs(page.controls) do control:Update() end + if page.OnRefresh then page.OnRefresh() end + page.updating = nil +end + +local function NewPage(name, parent, title, subtitle) + local page = CreateFrame("Frame") + page.name = name + page.parent = parent + page.controls = {} + + local heading = page:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge") + heading:SetPoint("TOPLEFT", page, "TOPLEFT", 16, -16) + heading:SetText(title) + page.y = -40 + if subtitle then + local text = page:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + text:SetPoint("TOPLEFT", heading, "BOTTOMLEFT", 0, -8) + text:SetPoint("RIGHT", page, "RIGHT", -32, 0) + text:SetJustifyH("LEFT") + text:SetText(subtitle) + page.y = -64 + end + + page.refresh = function() Snapshot(); RefreshControls(page) end + page.okay = function() saved = nil end + page.cancel = function() Restore() end + return page +end + +local function AddHeader(page, text) + local header = page:CreateFontString(nil, "ARTWORK", "GameFontNormal") + header:SetPoint("TOPLEFT", page, "TOPLEFT", 16, page.y - 8) + header:SetText(text) + page.y = page.y - 28 +end + +local function AddText(page, height) + local fs = page:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + fs:SetPoint("TOPLEFT", page, "TOPLEFT", 20, page.y) + fs:SetPoint("RIGHT", page, "RIGHT", -16, 0) + fs:SetJustifyH("LEFT") + page.y = page.y - (height or 16) + return fs +end + +-- get() / set(value); enabledIf() greys the box out while it returns false. +local function AddCheckbox(page, label, tooltip, get, set, enabledIf, x) + local check = CreateFrame("CheckButton", NextName(), page, "OptionsCheckButtonTemplate") + check:SetPoint("TOPLEFT", page, "TOPLEFT", x or 16, page.y) + local text = getglobal(check:GetName() .. "Text") + text:SetText(label) + check.tooltipText = tooltip + check:SetScript("OnClick", function() + set(this:GetChecked() and true or false) + RefreshControls(page) + end) + check.Update = function(self) + self:SetChecked(get()) + if not enabledIf or enabledIf() then + self:Enable() + text:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b) + else + self:Disable() + text:SetTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b) + end + end + table.insert(page.controls, check) + if not x then page.y = page.y - 30 end + return check, text +end + +------------------------------------------------------------------------------ +-- Page 1: ShardCap +------------------------------------------------------------------------------ +local main = NewPage("ShardCap", nil, "ShardCap " .. API.VERSION, + "Keeps your Soul Shards at a cap when you leave combat.") + +AddHeader(main, "Carrying") +local statusShards = AddText(main) +local statusReagents = AddText(main) +local statusBags = AddText(main, 22) + +-- Cap: slider for quick changes (0-100), box for an exact value (0-200) +AddHeader(main, "Shard cap") +local MAX_SLIDER, MAX_CAP = 100, 200 +local slider = CreateFrame("Slider", NextName(), main, "OptionsSliderTemplate") +slider:SetPoint("TOPLEFT", main, "TOPLEFT", 24, main.y - 14) +slider:SetWidth(220) +slider:SetMinMaxValues(0, MAX_SLIDER) +slider:SetValueStep(1) +slider.tooltipText = "Shards to keep. Anything above this is deleted when you leave combat." +getglobal(slider:GetName() .. "Text"):SetText("Shards to keep") +getglobal(slider:GetName() .. "Low"):SetText("0") +getglobal(slider:GetName() .. "High"):SetText(MAX_SLIDER) + +local box = CreateFrame("EditBox", NextName(), main, "InputBoxTemplate") +box:SetWidth(40) +box:SetHeight(20) +box:SetPoint("LEFT", slider, "RIGHT", 18, 0) +box:SetAutoFocus(false) +box:SetNumeric(true) +box:SetMaxLetters(3) +local boxFocus, boxCancel = false, false + +local function SetCap(v) + local db = DB() + if not db or v == db.cap then return end + db.cap = v + Changed() +end + +slider:SetScript("OnValueChanged", function() + local v = math.floor(this:GetValue() + 0.5) + if main.updating then return end + box:SetText(v) + SetCap(v) + if main.OnRefresh then main.OnRefresh() end +end) + +local function ApplyBox() + local v = tonumber(box:GetText()) + if v and v >= 0 and v <= MAX_CAP then + SetCap(math.floor(v)) + else + API.Print("The cap must be a whole number from 0 to " .. MAX_CAP .. ".") + end + RefreshControls(main) +end +box:SetScript("OnEditFocusGained", function() boxFocus = true end) +box:SetScript("OnEditFocusLost", function() + boxFocus = false + if boxCancel then boxCancel = false; RefreshControls(main) else ApplyBox() end +end) +box:SetScript("OnEnterPressed", function() this:ClearFocus() end) +box:SetScript("OnEscapePressed", function() boxCancel = true; this:ClearFocus() end) + +slider.Update = function(self) + local cap = DB().cap + if cap > MAX_SLIDER then self:SetValue(MAX_SLIDER) else self:SetValue(cap) end + if not boxFocus then box:SetText(cap) end +end +table.insert(main.controls, slider) +main.y = main.y - 50 + +local hint = AddText(main, 20) +hint:SetText("|cffaaaaaaType a value up to " .. MAX_CAP .. " in the box for caps above " .. MAX_SLIDER .. ".|r") + +AddHeader(main, "Options") +AddCheckbox(main, "Chat notifications", + "Print a line in chat whenever ShardCap deletes or moves something.", + function() return DB().notify end, function(v) DB().notify = v end) +AddCheckbox(main, "Trim during combat", + "Also delete shards above the cap while you are in combat. Off: wait until combat ends.", + function() return DB().combat end, function(v) DB().combat = v; Changed() end) + +local trim = CreateFrame("Button", NextName(), main, "UIPanelButtonTemplate") +trim:SetWidth(120) +trim:SetHeight(22) +trim:SetPoint("TOPLEFT", main, "TOPLEFT", 20, main.y - 8) +trim:SetText("Trim now") +trim:SetScript("OnClick", function() API.Schedule(0, true) end) +trim:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Delete shards above the cap right now, even in combat.", nil, nil, nil, nil, 1) +end) +trim:SetScript("OnLeave", function() GameTooltip:Hide() end) + +main.OnRefresh = function() + local shards, reagents, bags = API.StatusLines() + statusShards:SetText(shards) + statusReagents:SetText(reagents) + statusBags:SetText(bags) +end + +main.default = function() + local db = DB() + db.cap, db.notify, db.combat = DEFAULTS.cap, DEFAULTS.notify, DEFAULTS.combat + Changed() + RefreshControls(main) +end + +------------------------------------------------------------------------------ +-- Page 2: Soul Bag +------------------------------------------------------------------------------ +local bagPage = NewPage("Soul Bag", main.name, "Soul Bag", + "What goes in your soul bag, and which bags count as one.") + +AddHeader(bagPage, "Sorting") +AddCheckbox(bagPage, "Move shards into the soul bag", + "Move shards from your normal bags into free soul bag slots, and merge partial stacks.", + function() return DB().sort end, function(v) DB().sort = v; Changed() end) +AddCheckbox(bagPage, "Also move Demonic Figurines and Infernal Stones", + "Only uses soul bag slots that are not needed to hold your shard cap. " + .. "Skipped on servers where shards don't stack.", + function() return DB().reagents end, function(v) DB().reagents = v; Changed() end) + +AddHeader(bagPage, "Soul bags") +local function IsAuto() return DB().soulbags == nil end +AddCheckbox(bagPage, "Detect soul bags automatically", + "Recognizes soul bags by item type. Turn this off to choose the bags yourself.", + IsAuto, + function(v) + local db = DB() + if v then + db.soulbags = nil + else + db.soulbags = {} + for b = 1, 4 do if API.AutoIsSoulBag(b) then db.soulbags[b] = true end end + end + Changed() + end) + +local function BagName(bag) + local link = GetInventoryItemLink("player", ContainerIDToInventoryID(bag)) + if not link then return nil end + local _, _, name = string.find(link, "%[(.-)%]") + return name +end + +for bag = 1, 4 do + local b = bag + local check, label = AddCheckbox(bagPage, "Bag " .. b, + "Treat this bag as a soul bag. Only available when automatic detection is off.", + function() return API.IsSoulBag(b) end, + function(v) + local db = DB() + if not db.soulbags then db.soulbags = {} end + if v then db.soulbags[b] = true else db.soulbags[b] = nil end + Changed() + end, + function() return not IsAuto() and BagName(b) ~= nil end, + 36) + local update = check.Update + check.Update = function(self) + label:SetText("Bag " .. b .. ": " .. (BagName(b) or "|cff808080empty slot|r")) + update(self) + end + bagPage.y = bagPage.y - 26 +end + +bagPage.default = function() + local db = DB() + db.sort, db.reagents, db.soulbags = DEFAULTS.sort, DEFAULTS.reagents, nil + Changed() + RefreshControls(bagPage) +end + +------------------------------------------------------------------------------ +-- Live refresh while a page is shown (bags change as you loot) +------------------------------------------------------------------------------ +local function LiveRefresh(page) + local elapsed = 0 + page:SetScript("OnUpdate", function() + elapsed = elapsed + arg1 + if elapsed < 0.5 then return end + elapsed = 0 + if not boxFocus then RefreshControls(this) end + end) +end +LiveRefresh(main) +LiveRefresh(bagPage) + +InterfaceOptions_AddCategory(main) +InterfaceOptions_AddCategory(bagPage) + +API.OpenOptions = function() + InterfaceOptionsFrame_OpenToCategory(main) +end diff --git a/ShardCap.lua b/ShardCap.lua new file mode 100644 index 0000000..642ebce --- /dev/null +++ b/ShardCap.lua @@ -0,0 +1,595 @@ +--[[ 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 +} diff --git a/ShardCap.toc b/ShardCap.toc new file mode 100644 index 0000000..b564ce3 --- /dev/null +++ b/ShardCap.toc @@ -0,0 +1,8 @@ +## Interface: 11200 +## Title: ShardCap |cff9482c92.1|r +## Notes: Keeps your Soul Shards at a cap. Stack-aware for WoW 1.18.1 Clients, soul bag aware. Type /shardcap +## Author: The Johnnest of them all; stacking from ShardCapPlus (Paduk); 2.0 rework +## Version: 2.1.0 +## SavedVariablesPerCharacter: ShardCapDB, SHARDCAP_CAP_VALUE, SHARDCAP_SPAM +ShardCap.lua +Options.lua diff --git a/tests/test.lua b/tests/test.lua new file mode 100644 index 0000000..5328e1f --- /dev/null +++ b/tests/test.lua @@ -0,0 +1,471 @@ +-- Mock WoW 1.12 client + scenario tests for ShardCap. Run with Lua 5.0. +local ADDON_FILE = arg[1] or "ShardCap.lua" +local fails, passes = 0, 0 +local function check(cond, name) + if cond then passes = passes + 1; print(" ok " .. name) + else fails = fails + 1; print(" FAIL " .. name) end +end + +local now, bags, bagItem, cursor, combat, frames, chat, maxStack +local soulOK = {} +local function isSoulBagItem(id) return id == 22243 or id == 99999 end +local function fits(b, id) return not isSoulBagItem(bagItem[b]) or soulOK[id] end +local itemNames = { [6265] = "Soul Shard", [16265] = "Some Other Item", [6948] = "Hearthstone" } + +local function reset(opts) + opts = opts or {} + now, cursor, combat, frames, chat = 0, nil, false, {}, {} + maxStack = opts.maxStack or 3 + soulOK = { [6265] = true } + if opts.reagentsOK ~= false then soulOK[16583] = true; soulOK[5565] = true end + familyKnown = opts.familyKnown + bags, bagItem = {}, {} + for b = 0, 4 do bags[b] = { size = 0, slots = {} } end + ShardCapDB, SHARDCAP_CAP_VALUE, SHARDCAP_SPAM = nil, nil, nil + C_Container, C_Item, GetBagItem, CLASSIC_API_VERSION = nil, nil, nil, nil + bank = nil +end +local function setBag(b, size, itemID) bags[b].size = size; bagItem[b] = itemID end +local function put(b, s, id, n) bags[b].slots[s] = { id = id, count = n or 1 } end + +-- vanilla API +function GetTime() return now end +function GetContainerNumSlots(b) return bags[b] and bags[b].size or 0 end +function GetContainerItemLink(b, s) + local it = bags[b].slots[s]; if not it then return nil end + return "|cffffffff|Hitem:" .. it.id .. ":0:0:0|h[" .. (itemNames[it.id] or "?") .. "]|h|r" +end +function GetContainerItemInfo(b, s) + local it = bags[b].slots[s]; if not it then return nil end + return "tex", it.count, it.locked +end +function ContainerIDToInventoryID(b) return 19 + b end +function GetInventoryItemLink(u, inv) + local id = bagItem[inv - 19]; if not id then return nil end + return "|Hitem:" .. id .. ":0:0:0|h[bag]|h" +end +function GetItemInfo(id) + id = tonumber(id) + if id == 6265 then return "Soul Shard", "item:6265", 1, 0, "Reagent", "Reagent", maxStack end + if id == 16583 then return "Demonic Figurine", "item:16583", 1, 0, "Reagent", "Reagent", 10 end + if id == 5565 then return "Infernal Stone", "item:5565", 1, 0, "Reagent", "Reagent", 10 end + if id == 22243 then return "Small Soul Pouch", "item:22243", 2, 0, "Container", "Soul Bag", 1 end + if id == 99999 then return "Custom Soul Bag", "item:99999", 2, 0, "Container", "Soul Bag", 1 end + return "Bag", "item:" .. id, 1, 0, "Container", "Bag", 1 +end +function CursorHasItem() return cursor ~= nil end +function SpellIsTargeting() return false end +function PickupContainerItem(b, s) + local it = bags[b].slots[s] + if cursor then + if not fits(b, cursor.id) then return end -- "That item doesn't go in that container." + if it and it.id == cursor.id and it.count + cursor.count <= maxStack then + it.count = it.count + cursor.count; cursor = nil + elseif not it then + bags[b].slots[s] = cursor; cursor = nil + else + bags[b].slots[s] = cursor; cursor = it + end + elseif it then + bags[b].slots[s] = nil; cursor = it + end +end +function SplitContainerItem(b, s, n) + local it = bags[b].slots[s] + if cursor or not it or n >= it.count then return end + it.count = it.count - n; cursor = { id = it.id, count = n } +end +function DeleteCursorItem() cursor = nil end +function UnitAffectingCombat() return combat end +function IsAddOnLoaded() return false end +DEFAULT_CHAT_FRAME = { AddMessage = function(self, m) table.insert(chat, m) end } +SlashCmdList = {} +-- Minimal widget mock: enough of Frame/CheckButton/Slider/EditBox/FontString for Options.lua +local W = {} +local function call(w, script) + local fn = w.scripts[script] + if fn then local old = this; this = w; fn(); this = old end +end +function W:RegisterEvent(e) self.events[e] = true end +function W:SetScript(k, fn) self.scripts[k] = fn end +function W:Show() self.shown = true end +function W:Hide() self.shown = false end +function W:GetName() return self.name end +function W:SetText(t) self.text = t and tostring(t) or "" end +function W:GetText() return self.text end +function W:SetChecked(v) if v then self.checked = 1 else self.checked = nil end end +function W:GetChecked() return self.checked end +function W:Enable() self.enabled = true end +function W:Disable() self.enabled = false end +function W:SetMinMaxValues(lo, hi) self.lo, self.hi = lo, hi end +function W:GetValue() return self.value end +function W:SetValue(v) + if self.lo and v < self.lo then v = self.lo end + if self.hi and v > self.hi then v = self.hi end + if v ~= self.value then self.value = v; call(self, "OnValueChanged") end +end +function W:SetFocus() self.focus = true; call(self, "OnEditFocusGained") end +function W:ClearFocus() if self.focus then self.focus = false; call(self, "OnEditFocusLost") end end +function W:CreateFontString() return newWidget("FontString") end +for _, m in ipairs({ "SetPoint", "SetWidth", "SetHeight", "SetJustifyH", "SetTextColor", "SetValueStep", + "SetAutoFocus", "SetNumeric", "SetMaxLetters", "SetOwner" }) do W[m] = function() end end +local WMT = { __index = W } +function newWidget(kind, name) + local w = setmetatable({ kind = kind, name = name, scripts = {}, events = {}, shown = true, text = "", value = 0, enabled = true }, WMT) + if name then _G[name] = w end + table.insert(allWidgets, w) + return w +end +allWidgets = {} +function getglobal(n) return _G[n] end +function CreateFrame(kind, name, parent, template) + local f = newWidget(kind, name) + if name and template then + for _, sub in ipairs({ "Text", "Low", "High" }) do newWidget("FontString", name .. sub) end + end + table.insert(frames, f) + return f +end +GameTooltip = newWidget("GameTooltip") +NORMAL_FONT_COLOR = { r = 1, g = 0.82, b = 0 } +GRAY_FONT_COLOR = { r = 0.5, g = 0.5, b = 0.5 } + +local F +local function fire(e, a1) + if not F.events[e] then return end + event, arg1 = e, a1 + this = F; F.scripts.OnEvent() +end +local function run(seconds) + local stop = now + (seconds or 10) + while now < stop do + now = now + 0.05 + if F.shown and F.scripts.OnUpdate then this = F; F.scripts.OnUpdate() end + end +end +local OPTIONS_FILE = string.gsub(ADDON_FILE, "ShardCap%.lua$", "Options.lua") +local categories, bank +local function load(classic, withOptions) + categories = {} + if withOptions then + InterfaceOptions_AddCategory = function(p) p:Hide(); table.insert(categories, p) end + InterfaceOptionsFrame_OpenToCategory = function(p) p:Show(); p.refresh(p); categories.opened = p end + else + InterfaceOptions_AddCategory, InterfaceOptionsFrame_OpenToCategory = nil, nil + end + if classic then + CLASSIC_API_VERSION = 10000 + C_Container = { + GetContainerItemInfo = function(b, s) + local it = bags[b].slots[s]; if not it then return nil end + return { itemID = it.id, stackCount = it.count, isLocked = it.locked and true or false } + end, + GetContainerNumFreeSlots = function(b) + local id = bagItem[b] + local t = (id == 22243 or id == 99999) and 4 or 0 + return 0, t + end, + SwapItems = function(sb, ss, db_, ds) + local a, b2 = bags[sb].slots[ss], bags[db_].slots[ds] + if (a and not fits(db_, a.id)) or (b2 and not fits(sb, b2.id)) then return true end -- server refuses silently + bags[sb].slots[ss], bags[db_].slots[ds] = b2, a; return true + end, + MoveItem = function(sb, ss, db_, ds, n) + local src, dst = bags[sb].slots[ss], bags[db_].slots[ds] + if not src or n > src.count then return false end + if dst and (dst.id ~= src.id or dst.count + n > maxStack) then return false end + if dst then dst.count = dst.count + n else bags[db_].slots[ds] = { id = src.id, count = n } end + src.count = src.count - n; if src.count == 0 then bags[sb].slots[ss] = nil end + return true + end, + } + C_Item = { GetItemMaxStackSizeByID = function(id) return maxStack end, + GetItemFamily = function(id) + if not familyKnown then return 0 end -- family not reported: fall back to heuristics + return soulOK[id] and 4 or 0 + end } + end + if classic then + C_Item.GetItemCount = function(id, includeBank) + local t = 0 + for b = 0, 4 do for s = 1, bags[b].size do + local it = bags[b].slots[s]; if it and it.id == id then t = t + it.count end + end end + if includeBank and bank then t = t + (bank[id] or 0) end + return t + end + end + dofile(ADDON_FILE) + dofile(OPTIONS_FILE) + F = frames[1] + fire("ADDON_LOADED", "ShardCap") + fire("PLAYER_ENTERING_WORLD") +end +local function count(onlyBag) + local t = 0 + for b = 0, 4 do + if not onlyBag or onlyBag == b then + for s = 1, bags[b].size do + local it = bags[b].slots[s] + if it and it.id == 6265 then t = t + it.count end + end + end + end + return t +end +local function where(id) + local t = {} + for b = 0, 4 do for s = 1, bags[b].size do + local it = bags[b].slots[s]; if it and it.id == id then table.insert(t, b) end + end end + return t +end +local function slotsUsed() + local t = 0 + for b = 0, 4 do for s = 1, bags[b].size do + local it = bags[b].slots[s]; if it and it.id == 6265 then t = t + 1 end + end end + return t +end + +for _, classic in ipairs({ false, true }) do + local tag = classic and "[ClassicAPI] " or "[vanilla] " + print(tag) + + -- 1. WoW 1.18.1 Clients stacks of 3: trims to exactly the cap, backpack first + reset(); setBag(0, 16); setBag(4, 12, 22243) + put(0, 2, 6265, 3); put(4, 1, 6265, 3); put(4, 2, 6265, 3); put(4, 3, 6265, 2) + load(classic); ShardCapDB.cap = 6; ShardCapDB.sort = false; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + check(count() == 6, tag .. "trim 11 -> 6 with stacks of 3 (got " .. count() .. ")") + check(count(0) == 0, tag .. "deleted from backpack before soul bag") + check(cursor == nil, tag .. "cursor left empty") + + -- 2. Exactness: 13 shards, cap 12 -> deletes exactly 1 (ShardCapPlus deleted a whole stack of 3) + reset(); setBag(0, 16); setBag(4, 12, 22243) + put(0, 1, 6265, 1); for s = 1, 4 do put(4, s, 6265, 3) end + load(classic); ShardCapDB.cap = 12; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + check(count() == 12, tag .. "13 -> exactly 12 (got " .. count() .. ")") + + reset(); setBag(0, 16); setBag(4, 12, 22243) + put(0, 1, 6265, 3); for s = 1, 4 do put(4, s, 6265, 3) end + load(classic); ShardCapDB.cap = 13; ShardCapDB.sort = false; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + check(count() == 13 and count(0) == 1, tag .. "15 -> 13 splits the backpack stack (got " .. count() .. ")") + + -- 3. No false positives: item 16265 is never touched + reset(); setBag(0, 16) + put(0, 1, 16265, 1); put(0, 2, 6265, 1); put(0, 3, 6265, 1) + load(classic); ShardCapDB.cap = 1; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + check(bags[0].slots[1] and bags[0].slots[1].id == 16265, tag .. "item 16265 kept") + check(count() == 1, tag .. "shards trimmed to 1") + + -- 4. Merge partial stacks into the soul bag stack + reset(); setBag(0, 16); setBag(4, 12, 22243) + put(0, 5, 6265, 1); put(4, 1, 6265, 2); put(4, 2, 6265, 1) + load(classic); ShardCapDB.cap = 20; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + check(count() == 4, tag .. "merge keeps all 4 shards") + check(slotsUsed() == 2, tag .. "4 shards in 2 slots after merging (got " .. slotsUsed() .. ")") + check(count(0) == 0, tag .. "backpack emptied into soul bag") + + -- 5. Blizzlike (no stacking): move shards into free soul bag slots + reset({ maxStack = 1 }); setBag(0, 16); setBag(2, 16, 22243) + put(0, 1, 6265); put(0, 2, 6265); put(2, 1, 6265) + load(classic); ShardCapDB.cap = 20; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + check(count(2) == 3 and count(0) == 0, tag .. "shards moved into soul bag in bag 2") + + -- 6. Combat: nothing deleted in combat, trimmed on leaving it + reset(); setBag(0, 16) + for s = 1, 5 do put(0, s, 6265, 1) end + load(classic); run(); ShardCapDB.cap = 2 + combat = true; fire("PLAYER_REGEN_DISABLED"); put(0, 6, 6265, 1); fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + check(count() == 6, tag .. "no deletion during combat") + combat = false; fire("PLAYER_REGEN_ENABLED"); run() + check(count() == 2, tag .. "trimmed after combat (got " .. count() .. ")") + + -- 7. Manual /shardcap delete works in combat + reset(); setBag(0, 16); for s = 1, 4 do put(0, s, 6265, 1) end + load(classic); combat = true; fire("PLAYER_REGEN_DISABLED"); ShardCapDB.cap = 1 + SlashCmdList.SHARDCAP("delete"); run() + check(count() == 1, tag .. "manual delete in combat") + + -- 8. Holding an item on the cursor blocks deletion + reset(); setBag(0, 16); for s = 1, 4 do put(0, s, 6265, 1) end + cursor = { id = 6948, count = 1 } + load(classic); ShardCapDB.cap = 1; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run(2) + check(count() == 4 and cursor.id == 6948, tag .. "waits while the cursor holds an item") + cursor = nil; run() + check(count() == 1, tag .. "proceeds once the cursor is free") + + -- 9. Locked stack is skipped until unlocked + reset(); setBag(0, 16); put(0, 1, 6265, 1); put(0, 2, 6265, 1) + bags[0].slots[2].locked = true; bags[0].slots[1].locked = true + load(classic); ShardCapDB.cap = 0; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run(1) + check(count() == 2, tag .. "locked stacks untouched") + bags[0].slots[2].locked = nil; bags[0].slots[1].locked = nil; run() + check(count() == 0, tag .. "deleted after unlock") + + -- 10. Reagents go into the soul bag when there's room beyond the shard reserve + reset({ familyKnown = true }); setBag(0, 16); setBag(4, 12, 22243) + put(0, 1, 16583, 2); put(0, 2, 5565, 7); put(4, 1, 6265, 3) + load(classic); ShardCapDB.cap = 12; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + check(where(16583)[1] == 4 and where(5565)[1] == 4, tag .. "figurine and infernal stone moved to soul bag") + check(bags[4].slots[12] ~= nil and bags[4].slots[11] ~= nil, tag .. "reagents fill soul bag from the end") + check(count() == 3, tag .. "shards untouched by reagent moves") + + -- 11. Reserve: cap 12 / 3 per slot = 4 slots kept for shards + reset({ familyKnown = true }); setBag(0, 16); setBag(4, 5, 22243) + put(0, 1, 16583, 1); put(0, 2, 5565, 1); put(4, 1, 6265, 1) + load(classic); ShardCapDB.cap = 12; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + local moved = 0 + if where(16583)[1] == 4 then moved = moved + 1 end + if where(5565)[1] == 4 then moved = moved + 1 end + check(moved == 1, tag .. "only 1 reagent moved into a 5-slot soul bag (4 kept for shards), got " .. moved) + + -- 12. Shards outside the soul bag get the free slots first + reset({ familyKnown = true }); setBag(0, 16); setBag(4, 2, 22243) + put(0, 1, 16583, 1); put(0, 2, 6265, 1); put(0, 3, 6265, 1) + load(classic); ShardCapDB.cap = 6; ShardCapDB.sort = true; fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + check(where(16583)[1] == 0, tag .. "reagent waits when shards need the space") + + -- 13. Server that doesn't allow reagents in soul bags: gives up, no endless retries + reset({ reagentsOK = false }); setBag(0, 16); setBag(4, 12, 22243) + put(0, 1, 16583, 1); put(0, 2, 5565, 1) + load(classic); ShardCapDB.cap = 3; local before = table.getn(chat) + fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run(20) + check(where(16583)[1] == 0 and where(5565)[1] == 0 and cursor == nil, tag .. "refused reagents stay put, cursor empty") + check(table.getn(chat) - before == 2, tag .. "one notice per refused item (got " .. (table.getn(chat) - before) .. ")") + check(not F.shown, tag .. "scheduler goes idle after giving up") + + -- 14. Blizzlike server (shards don't stack): reagents left alone + reset({ maxStack = 1, reagentsOK = false }); setBag(0, 16); setBag(4, 12, 22243) + put(0, 1, 16583, 1) + load(classic); fire("BAG_UPDATE"); fire("BAG_UPDATE_DELAYED"); run() + check(where(16583)[1] == 0 and table.getn(chat) == 0, tag .. "no attempt on Blizzlike servers") + + -- 15. Toggle off + reset({ familyKnown = true }); setBag(0, 16); setBag(4, 12, 22243); put(0, 1, 5565, 1) + load(classic); SlashCmdList.SHARDCAP("reagents"); run() + check(where(5565)[1] == 0 and ShardCapDB.reagents == false, tag .. "/shardcap reagents turns it off") +end + +-- 10. Migration from ShardCap 1.x and slash parsing +print("[misc]") +reset(); setBag(0, 16) +dofile(ADDON_FILE); F = frames[1] +SHARDCAP_CAP_VALUE, SHARDCAP_SPAM = 7, true +fire("ADDON_LOADED", "ShardCap") +check(ShardCapDB.cap == 7 and ShardCapDB.notify == true, "migrated 1.x cap and spam setting") +check(SHARDCAP_CAP_VALUE == nil, "old globals cleared") +SlashCmdList.SHARDCAP("-3"); check(ShardCapDB.cap == 7, "negative cap rejected") +SlashCmdList.SHARDCAP("2.5"); check(ShardCapDB.cap == 7, "fractional cap rejected") +SlashCmdList.SHARDCAP(" 15 "); check(ShardCapDB.cap == 15, "cap set with whitespace") +SlashCmdList.SHARDCAP("soulbag 3"); check(ShardCapDB.soulbags and ShardCapDB.soulbags[3], "manual soul bag") +SlashCmdList.SHARDCAP("soulbag auto"); check(ShardCapDB.soulbags == nil, "back to auto") +SlashCmdList.SHARDCAP("status"); SlashCmdList.SHARDCAP("help") +check(table.getn(chat) > 5, "status/help print") +reset(); setBag(0, 16); put(0, 1, 6265, 3); put(0, 2, 6265, 2) +load(false); ShardCapDB.cap = 12; chat = {}; SlashCmdList.SHARDCAP("") +check(string.find(chat[2] or "", "Demonic Figurines: |cffffffff0|r Infernal Stones: |cffffffff0|r", 1, true) ~= nil, "reagent line with none carried") +reset(); setBag(0, 16); setBag(4, 12, 22243); put(0, 1, 6265, 3); put(0, 2, 6265, 2) +put(0, 3, 16583, 2); put(4, 5, 16583, 1); put(0, 4, 5565, 7) +load(false); ShardCapDB.cap = 12; ShardCapDB.reagents = false; chat = {}; SlashCmdList.SHARDCAP("") +check(string.find(chat[2] or "", "Demonic Figurines: |cffffffff3|r Infernal Stones: |cffffffff7|r", 1, true) ~= nil, "reagents counted across all bags incl. soul bag (got: " .. (chat[2] or "nil") .. ")") +check(string.find(chat[1] or "", "Soul Shards: |cffffffff5/12|r", 1, true) ~= nil, "/shardcap shows 5/12 (got: " .. (chat[1] or "nil") .. ")") + +-- 11. Unknown custom soul bag found through its "Soul Bag" subtype +reset(); setBag(0, 16); setBag(3, 20, 99999); put(0, 1, 6265, 1) +load(false); ShardCapDB.cap = 10; fire("BAG_UPDATE"); run() +check(count(3) == 1, "soul bag detected by subtype") + +-- 12. Options window (ClassicAPI AddOn Options) +print("[options]") +local function findControl(page, label) + for _, c in ipairs(page.controls) do + local t = c.name and _G[c.name .. "Text"] + if t and string.find(t.text, label, 1, true) then return c end + end +end +local function click(c) if c.checked then c.checked = nil else c.checked = 1 end; call(c, "OnClick") end +local function findKind(page, kind) + for _, c in ipairs(page.controls) do if c.kind == kind then return c end end +end + +reset(); setBag(0, 16); setBag(4, 12, 22243) +put(0, 1, 6265, 3); put(0, 2, 6265, 2); put(4, 1, 16583, 2); put(0, 3, 5565, 4) +bank = { [6265] = 4, [5565] = 10 } +load(true, true); ShardCapDB.cap = 12 +local main, bp = categories[1], categories[2] +check(main and main.name == "ShardCap" and bp and bp.name == "Soul Bag" and bp.parent == "ShardCap", "two pages registered, Soul Bag under ShardCap") + +SlashCmdList.SHARDCAP("options") +check(categories.opened == main and main.shown, "/shardcap options opens the ShardCap page") +local texts = {} +for _, f in ipairs(allWidgets) do if f.kind == "FontString" and f.text ~= "" then table.insert(texts, f.text) end end +local joined = table.concat(texts, "\n") +check(string.find(joined, "5/12", 1, true) ~= nil, "status shows 5/12") +check(string.find(joined, "(+4 in bank)", 1, true) ~= nil, "status shows shards in bank") +check(string.find(joined, "Infernal Stones: |cffffffff4|r |cffaaaaaa(+10 in bank)|r", 1, true) ~= nil, "status shows stones with bank count") + +local slider = findKind(main, "Slider") +check(slider.value == 12, "slider shows the cap") +slider:SetValue(30) +check(ShardCapDB.cap == 30, "slider sets the cap") +local box +for _, f in ipairs(frames) do if f.kind == "EditBox" then box = f end end +check(box.text == "30", "box follows the slider") +box:SetFocus(); box:SetText("150"); call(box, "OnEnterPressed") +check(ShardCapDB.cap == 150 and slider.value == 100, "box sets caps above the slider range") +box:SetFocus(); box:SetText("250"); call(box, "OnEnterPressed") +check(ShardCapDB.cap == 150 and box.text == "150", "out-of-range box value rejected") +box:SetFocus(); box:SetText("7"); call(box, "OnEscapePressed") +check(ShardCapDB.cap == 150 and box.text == "150", "Escape in the box discards the edit") + +local notify = findControl(main, "Chat notifications") +click(notify); check(ShardCapDB.notify == true, "notifications checkbox") + +-- Cancel restores what was there when the window opened; Okay keeps changes +main.cancel(main); bp.cancel(bp) +check(ShardCapDB.cap == 12 and ShardCapDB.notify == false, "Cancel restores settings") +SlashCmdList.SHARDCAP("options"); slider:SetValue(20); main.okay(main); bp.okay(bp) +SlashCmdList.SHARDCAP("options"); main.cancel(main) +check(ShardCapDB.cap == 20, "Okay keeps settings") + +-- Defaults reset only the page shown +ShardCapDB.notify = true; ShardCapDB.reagents = false +main.default(main) +check(ShardCapDB.cap == 12 and ShardCapDB.notify == false and ShardCapDB.reagents == false, "Defaults on ShardCap page leave Soul Bag settings") +bp.default(bp) +check(ShardCapDB.reagents == true, "Defaults on Soul Bag page") + +-- Soul bag page +bp:Show(); bp.refresh(bp) +local auto = findControl(bp, "Detect soul bags automatically") +local bag4 = findControl(bp, "Bag 4") +local bag2 = findControl(bp, "Bag 2") +check(auto.checked and bag4.checked and not bag4.enabled, "auto detection on; bag 4 shown as soul bag but locked") +check(not bag2.enabled and string.find(_G[bag2.name .. "Text"].text, "empty slot", 1, true) ~= nil, "empty bag slot greyed out") +click(auto) +check(type(ShardCapDB.soulbags) == "table" and ShardCapDB.soulbags[4] and bag4.enabled, "manual mode seeded from detection") +click(bag4) +check(not ShardCapDB.soulbags[4], "bag checkbox clears a soul bag") +click(auto) +check(ShardCapDB.soulbags == nil, "back to auto") +local reag = findControl(bp, "Demonic Figurines and Infernal Stones") +click(reag); check(ShardCapDB.reagents == false, "reagents checkbox") + +-- Live refresh only while shown +put(0, 4, 6265, 1) +this = main; arg1 = 0.6; main.scripts.OnUpdate() +texts = {} +for _, f in ipairs(allWidgets) do if f.kind == "FontString" then table.insert(texts, f.text) end end +check(string.find(table.concat(texts, "\n"), "6/12", 1, true) ~= nil, "status refreshes while open") + +-- Without ClassicAPI's options window +reset(); setBag(0, 16) +load(false, false); chat = {} +SlashCmdList.SHARDCAP("options") +check(table.getn(categories) == 0 and string.find(chat[1] or "", "needs ClassicAPI", 1, true) ~= nil, "no panel without ClassicAPI; slash explains") +SlashCmdList.SHARDCAP("") +check(string.find(chat[2] or "", "5/12", 1, true) == nil and string.find(chat[2] or "", "in bank", 1, true) == nil, "no bank text without ClassicAPI") + +print(string.format("\n%d passed, %d failed", passes, fails)) +if fails > 0 then os.exit(1) end