Apply Patch

This commit is contained in:
2026-10-30 16:54:29 +00:00
parent 46a3e26b5a
commit d9634b06d5
4 changed files with 1404 additions and 0 deletions
+330
View File
@@ -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
+595
View File
@@ -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 <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
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<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
}
+8
View File
@@ -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
+471
View File
@@ -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