Lucky's Utils
Shared library for Lucky Phil's WoW addons. Provides a consistent UI theme, reusable frame components, settings panel builder, minimap button factory, and general utilities.
This addon is a dependency — it does nothing on its own. If another addon asked you to install it, you're in the right place. No configuration is needed.
What's Included
- LuckyUI — dark/gold colour palette and frame builders: styled panels, headers, buttons, checkboxes, dividers, search inputs, and drag-to-move with position persistence. Ships a set of white line icons any addon can draw and tint.
- LuckySettings — fluent builder for Interface Options panels: toggles, selectors, sliders, and section headings.
- LuckyRichSettings — settings panel with grouped navigation, hover descriptions, status badges, single-choice and check-list dropdowns, inline notice banners, warning icons on rows that carry a risk, options that can be marked unavailable, and an optional About panel with screenshots, notes, and "What's New" highlights.
- LuckyRoster — shared, account-wide character roster (names, classes, professions) so dependent addons see one consistent list.
- LuckyProfiles — turn any config table into a copy-paste share string and read it back, with a checksum so corrupted strings fail cleanly. Includes ready-made export and import panels.
- LuckyItem — load item and spell data reliably, with a callback that only fires once the name, icon and link have actually arrived from the server. Results are cached for the session, with a batch helper for flicker-free list rendering.
- LuckyMinimap — draggable minimap buttons with saved position and visibility state, plus one-call registration in Blizzard's AddOn Compartment, no extra library required.
- LuckyBugs — watches for errors in any Lucky Phil addon and offers to walk you through reporting them on the Discord, with a copy-ready report.
- LuckyStrings — seals a table of user-facing strings so a mistyped key shows up on screen as a red placeholder naming the key, instead of a blank label.
- LuckyLog — gated debug loggers that stay silent until an addon's debug flag is on.
- LuckyDeps — optional dependency checks with version validation.
- LuckyInstance: one shared answer to "what content is the player in?" (open world, raid, Mythic+, dungeon, delve, battleground, arena, scenario), so every addon recognises a delve or a keystone the same way.
- LuckySound — helpers for addon sound files and built-in WoW sound kit entries.
- LuckyUtils — general utilities: recursive SavedVariables initialisation and canonical
Name-Realmcharacter keys. - LuckyDB: transactional, sequential SavedVariables migrations with recursive defaults and schema version checks.
- LuckyBankQueue: sequential container transfers with lock polling, cursor recovery, partial-stack support, and destination retries.
- LuckyBankRun: runs every Lucky addon's bank jobs one at a time and lists the items still to move in a window beside the bank.
- LuckyReminders: one Reminders window, opened at login and on entering a rest area, that any Lucky addon adds rows to.
For Addon Developers
Installation as a dependency
Embed the library in packaged releases via .pkgmeta externals:
externals:
YourAddon/Libs/LuckysUtils:
url: https://github.com/LuckyPhilDev/LuckyUtils
tag: latest
In your .toc, load the library before your own files, declare its SavedVariables alongside yours (an embedded library cannot declare its own), and keep OptionalDeps so the standalone checkout loads first in development, where Libs/LuckysUtils does not exist:
## OptionalDeps: Luckys_Utils
## SavedVariables: YourAddonDB, LuckySettingsDB, LuckyRosterDB
Libs\LuckysUtils\embeds.xml
YourFirstFile.lua
Every copy registers LuckysUtils-1.0 with LibStub and only one copy's files run. The highest MINOR wins; where two copies tie, an embedded copy takes the registration from the standalone addon, and otherwise the first loaded keeps it. A copy from before the gate replaces the published tables instead of merging into them; the winner remembers what it published and restores it as later addons load, so one of those cannot strip the API out from under a consumer. The winner publishes the same globals (LuckyUI, LuckySettings, LuckyRichSettings, LuckyRoster, LuckyMinimap, LuckyProfiles, LuckyItem, LuckyStrings, LuckyLog, LuckyDeps, LuckySound, LuckyInstance, LuckyUtils, LuckyDB, LuckyBankQueue, LuckyBankRun, LuckyReminders, LuckyBugs, plus LuckyMedia(fileName) and LuckyIcon(name) for paths into the library Media folder), available once the library has loaded, whichever addon carried it. To see which copy won in-game: /dump LibStub.minors["LuckysUtils-1.0"], and /dump LuckysUtilsHosts for every copy that loaded.
Where an embedded copy is loaded and no installed addon lists Luckys_Utils as a required dependency, a panel tells the player once that the standalone addon can be uninstalled.
LuckyUtils
-- Apply defaults to a SavedVariables table (recursive for nested tables)
LuckyUtils.ApplyDefaults(MyAddonDB, {
devMode = false,
showPanel = true,
threshold = { min = 1, max = 10 },
})
-- Canonical "Name-Realm" key for the current character
local key = LuckyUtils.CharacterKey() -- e.g. "Tharindel-Silvermoon"
-- Plain "12g 34s 56c" chat text from a copper amount
local cost = LuckyUtils.FormatMoney(123456)
-- Run once per burst of calls, after the delay
local refresh = LuckyUtils.Debounced(0.25, RebuildList)
LuckyStrings
A plain string table returns nil for a mistyped key, which reaches SetText as a blank label or a hard error frames later. Sealing swaps that for a red placeholder naming the exact key, so the typo shows in the first screenshot.
MyAddon.Strings = LuckyStrings.New("MyAddon.Strings", {
minimap = { drag = "Drag: Move button" },
})
local S = MyAddon.Strings.minimap
S.drag --> "Drag: Move button"
S.dragg --> "|cffff0000[MyAddon.Strings.minimap.dragg]|r"
Nested tables are sealed too, and the table is sealed in place, so pairs and ipairs still work.
LuckyDB
Migrations run in version order on a working copy. The original SavedVariables table keeps its identity and is only changed after every migration succeeds.
local database, versionOrError = LuckyDB:Initialize(MyAddonDB, {
version = 2,
defaults = {
enabled = true,
alerts = { sound = true },
},
migrations = {
[1] = function(data)
data.alerts = { sound = data.playSound ~= false }
data.playSound = nil
end,
[2] = function(data)
data.enabled = data.enabled ~= false
end,
},
})
if not database then
error("Database migration failed: " .. versionOrError)
end
The schema version is stored in __schemaVersion by default. Pass versionKey to use a different field. A migration may raise an error or return false, message; either result rolls back the entire upgrade.
LuckyBankQueue
Provide a destination resolver, enqueue source slots, then start the queue. Native WoW container, cursor, and timer APIs are used unless test doubles are supplied.
local queue = LuckyBankQueue:New({
findDestination = function(itemID, excluded, step)
return MyAddon.FindWarbandDestination(itemID, excluded, step)
end,
onError = function(_, step, code)
LuckyLog("MyAddon", "Transfer failed", step.itemID, code)
end,
})
queue:Enqueue({
sourceBag = sourceBag,
sourceSlot = sourceSlot,
itemID = itemID,
amount = amount,
})
queue:Start(function()
MyAddon:RefreshBankState()
end)
findDestination(itemID, excluded, step) returns a bag and slot. The queue waits for locked slots, skips incompatible slots, retries when a partial merge leaves an item on the cursor, restores the cursor item to its source on failure, and reports stable error codes through onError. Use GetPendingCount(), IsRunning(), and Cancel() to inspect or stop a queue.
LuckyBankRun
Every addon that moves items at the bank hands its work to one shared run, so two addons never pick items up at the same time. A job is a plan and a run: the plan says what will move, the run moves it, ticking each move off and saying when it is done.
LuckyBankRun:OnBankOpen(20, {
direction = "deposit", -- or "withdraw"; drawn as an arrow on each row
plan = function() return PlanDeposits() end, -- { { itemID = n }, ... } in run order
run = function(job, moves)
DepositAll(moves, function() job:Tick() end, function() job:Done() end)
end,
})
Jobs registered with OnBankOpen(order, job) run every time the bank opens, lowest order first; deposits take lower numbers than withdrawals so the bags have room. Orders in use: Grab-bag whitelist and lumber 10, Grab-bag reagents 15, Stockist warbound gear 20, Stockist excess deposit 30, Grab-bag treatises 40, Stockist restock 50, Stockist bank sort 90. Keep every deposit below every withdrawal. Every job is planned before the first one runs, so the window lists the whole run from the start; a run must re-check the bags and bank as it goes, since the jobs before it move things. Queue(job) plans and runs a one-off job, such as a slash command, after whatever is already running. A run must call job:Done() exactly once, including when it has nothing to do, and should step to each next move with job:After(delay, fn) in place of C_Timer.After: that is where Pause holds the job, and where a dropped job stops. Moves it planned but never ticked count as passed when it finishes. An error inside a plan or a run is reported and the run carries on. Closing the bank drops every job still waiting, and a late Tick or Done from a dropped job is ignored.
The window beside the bank (Blizzard's or Baganator's) lists the planned items, counts the moves in its title and fills a bar as they finish. While a run is going, a Pause button at its top holds the run before the next move, LuckyBankRun:Pause(), and Resume carries on, LuckyBankRun:Resume(). LuckyBankRun:AddSettingsToggle(group, since) adds the account-wide Hide Bank Queue toggle to a rich settings group; every addon's copy of the toggle shares one flag in LuckySettingsDB.
LuckyBankRun:AddModeSetting(group, { since = ..., ownedBy = ... }) adds the account-wide Auto or Manual choice. In Manual mode the bank-open jobs wait: the window opens listing every planned item under a Start button, which calls LuckyBankRun:StartBankJobs(), and the Hide toggle does not apply. Pass ownedBy with another installed addon's name to lock that addon's copy of the row and point the player there.
LuckyBankRun:AddFooter(footer) adds a line to the foot of the window, under a divider. footer() returns the text, or nil for none, and is asked again on every redraw.
LuckyReminders
One window of things to go and do. It opens two seconds after login and whenever the player enters a rest area, provided at least one source has a row to show. Each addon registers a source:
LuckyReminders:Register("lowStock", {
title = "Low Stock", -- optional; a section without one has no title line
order = 20, -- lowest first, default 100
rows = function() return { { itemID = 1234, detail = "3 / 20" } } end,
})
rows() is asked again on every redraw and returns nil or an empty list while there is nothing to remind about. A row is { icon, text, detail }, or { itemID, detail } to have the item's icon and link filled in; a section of item rows sorts by name, and a section longer than twelve rows is cut with a count of the rest. A source that errors is reported and skipped. Orders in use: Grab-bag 10, Stockist low stock 20.
LuckyReminders:Refresh() redraws the window if it is up, and closes it once the last row clears; call it when a source's rows change. It never opens the window. LuckyReminders:Show() opens it and starts the timer.
LuckyReminders:AddSettings(group, since) adds the account-wide timer slider, the dimming sliders and the Keep Reminders Open While Resting toggle to a rich settings group. A timer of 0 keeps the window up until it is closed. Dim Reminders After fades the window to the Dimmed Opacity once it has sat unhovered that long, 0 meaning never; hovering brings it back. The values and the window position live in LuckySettingsDB.reminders, read through LuckyReminders.Saved().
LuckyUI
Colour palette — LuckyUI.C (RGBA 0–1 tables) and LuckyUI.WC (WoW colour escape strings):
-- Print with gold accent
print(LuckyUI.WC.goldPrimary .. "MyAddon" .. LuckyUI.WC.reset .. ": hello")
Key palette tokens: bgDark, bgPanel, bgInput, goldPrimary, goldAccent, goldMuted, textLight, textMuted, danger, info, success, purple.
LuckyUI.DOT is the separator glyph for joining label parts, a middle dot on every client whose font can draw one and a hyphen on ruRU, which cannot. Add your own spacing around it:
local label = name .. " " .. LuckyUI.DOT .. " " .. realm
Frame helpers:
-- Standard window: gold-bordered panel, header with title and close button,
-- dragged by the header, position saved to db[key], closes on Escape.
-- Starts hidden. Content starts LuckyUI.HEADER_HEIGHT + 1 below the top.
local win, header = LuckyUI.CreateWindow("MyWindow", 400, 300, "My Addon", {
db = MyAddonDB, key = "windowPos", -- optional; other fields in db[key] are kept
strata = "MEDIUM", -- default "DIALOG"
})
-- win.titleText, win.closeButton, win.header, win:RestorePosition()
-- React to closing with win:HookScript("OnHide", ...): Escape skips the close button.
-- The parts CreateWindow is built from, for frames that need something else
local panel = LuckyUI.CreatePanel("MyPanel", UIParent, 400, 300) -- draggable anywhere
LuckyUI.CreateHeader(panel, "My Addon")
-- Buttons: "primary" | "secondary" (default) | "danger". Dimmed while disabled.
local btn = LuckyUI.CreateButton(parent, "Save", 90, 28, "primary")
-- The same look on a button you made: a /click name or a secure template.
-- It must inherit BackdropTemplate.
local go = CreateFrame("Button", "MyAddonGo", parent, "BackdropTemplate,SecureActionButtonTemplate")
LuckyUI.StyleButton(go, "Go", "primary")
-- Text input: gold border while focused, Enter and Escape drop focus
local name = LuckyUI.CreateInput(parent, { width = 240, height = 24, maxLetters = 32 })
-- Checkbox (gold fill when checked)
local cb = LuckyUI.CreateCheckbox(parent, 16)
-- Horizontal divider with optional label
local div = LuckyUI.CreateDivider(parent, "Section Title")
-- Gold icon button. tooltip is text, or fn(GameTooltip, button) for a live one.
local icon = LuckyUI.CreateIconButton(parent, {
icon = "Interface\\Icons\\INV_Misc_Map_01",
size = 22,
tooltip = "Minimap Button",
texCoord = { 0.08, 0.92, 0.08, 0.92 }, -- crops the border baked into Icons art
})
icon:SetScript("OnClick", ToggleMinimapButton)
-- Adds icon:SetIconColor(r, g, b, a) and icon:SetIconDesaturated(bool)
-- `icon` also takes the bare name of one of the shared icons, which the library
-- resolves and the button tints. Anything with a path separator in it is left
-- alone as your own texture.
local tick = LuckyUI.CreateIconButton(parent, { icon = "check", size = 18 })
-- The shared set, as white line art on transparent, tinted by whatever draws it:
-- arrow-down-to-line, arrow-up-from-line, check, copy, crosshair, crown, dice,
-- eraser, filter, layers, pause, pencil, play, plus, portal, search, settings,
-- sparkle, square, square-pen, target, trash, triangle-alert, x. LuckyIcon(name)
-- gives the full
-- texture path for one.
-- Square button for full-colour Interface\Icons art, action bar highlight
local act = LuckyUI.CreateActionButton(parent, {
texture = 136243,
size = 42, -- default
name = "MyAddonAction", -- optional global name
template = "SecureActionButtonTemplate", -- optional
tooltip = function(tip, btn) tip:SetText("Do the thing") end,
})
-- The column of buttons down the right of a window, shared by every Lucky
-- addon under the same key so their buttons stack instead of overlapping.
-- Hidden buttons leave no gap. Right-drag any button to move the whole column;
-- its offset from the window is saved in LuckySettingsDB.
local column = LuckyUI.SideColumn("AuctionHouse", AuctionHouseFrame)
local buy = column:AddButton({ texture = 133784, order = 10 }) -- CreateActionButton opts, lowest order on top
-- Carry over a position your addon saved itself, used only when none is saved yet
LuckyUI.SeedSideColumnPosition("AuctionHouse", MyAddonDB.ahButtonPos)
-- Drag-to-move with SavedVariables persistence
LuckyUI.EnableDrag(myFrame, {
db = MyAddonDB,
key = "windowPos",
default = { "TOP", "TOP", 0, -200 },
})
-- Restores saved position immediately; adds myFrame:RestorePosition()
-- Search input with placeholder and clear button
local search = LuckyUI.CreateSearchBox(parent, {
width = 220,
placeholder = "Search mounts...",
onChange = function(query) FilterList(query) end, -- query is trimmed
})
search:SetPoint("TOPLEFT", 12, -12)
-- Adds search:SetQuery(text) and search:Clear(); both fire onChange
Virtualised scrolling list — builds only enough row frames to fill the visible area and recycles them as you scroll, so a few hundred rows stay smooth. You own the look of a row; the list owns scrolling and pooling. Pairs naturally with the search box above.
local list = LuckyUI.CreateScrollList(panel, {
rowHeight = 24,
-- Build a row once; return the frame. Called per pooled slot, not per item.
createRow = function(parent)
local row = CreateFrame("Frame", nil, parent)
row.text = row:CreateFontString(nil, "OVERLAY")
row.text:SetFont(LuckyUI.BODY_FONT, 12)
row.text:SetPoint("LEFT", 6, 0)
return row
end,
-- Fill a row from a data entry. Called whenever a slot is reused.
updateRow = function(row, item, index) row.text:SetText(item.name) end,
onClick = function(item, index) print("clicked", item.name) end, -- optional
})
list:SetPoint("TOPLEFT", 12, -48)
list:SetSize(280, 360)
list:SetData(myArray) -- replace data and redraw from the top
list:Refresh() -- redraw visible rows after editing the data in place
list:ScrollTo(20) -- scroll a 1-based index to the top
LuckySettings
local panel = LuckySettings:NewPanel("My Addon")
panel:Section("General")
panel:Toggle({
label = "Enable feature",
desc = "Turns the feature on or off.",
checked = MyAddonDB.featureEnabled,
onToggle = function(v) MyAddonDB.featureEnabled = v end,
})
panel:Selector({
label = "Mode",
value = MyAddonDB.mode,
choices = { { value="auto", label="Auto" }, { value="manual", label="Manual" } },
onChange = function(v) MyAddonDB.mode = v end,
})
panel:Slider({
label = "Threshold",
key = "threshold",
min = 1, max = 100, value = MyAddonDB.threshold,
suffix = "%",
onChanged = function(v) MyAddonDB.threshold = v end,
})
-- Open programmatically
panel:Open()
-- or: LuckySettings:Open(panel.category)
LuckyMinimap
LuckyMinimap:Create({
name = "MyAddonMinimapButton",
icon = "Interface\\Icons\\INV_Misc_Bag_36",
dbKey = "minimap", -- key in db for { minimapPos, hide }
db = MyAddonDB,
onClick = function(_, mouseBtn)
if mouseBtn == "LeftButton" then MyAddon:Toggle() end
end,
tooltip = function(tt)
tt:AddLine(LuckyUI.WC.goldPrimary .. "My Addon" .. LuckyUI.WC.reset)
tt:AddLine("Left-click: Toggle", 0.91, 0.86, 0.78)
tt:AddLine("Drag: Move button", 0.54, 0.49, 0.42)
end,
})
AddOn Compartment — register the addon in Blizzard's native button list at the top of the minimap, as an alternative or companion to the minimap button. The same onClick and tooltip functions drive both, so a user running several Lucky addons can collapse the cluster of minimap buttons into one menu.
LuckyMinimap:RegisterCompartment({
name = "My Addon",
icon = "Interface\\Icons\\INV_Misc_Bag_36",
onClick = function(_, mouseBtn)
if mouseBtn == "LeftButton" then MyAddon:Toggle() end
end,
tooltip = function(tt)
tt:AddLine(LuckyUI.WC.goldPrimary .. "My Addon" .. LuckyUI.WC.reset)
tt:AddLine("Click: Toggle", 0.91, 0.86, 0.78)
end,
})
-- Returns false on older clients with no compartment; safe to ignore.
LuckyProfiles
Export and import any config table as a single share string, so users can copy a setup between characters or send it to a friend. The string carries a checksum, so a truncated or mistyped paste is rejected with a clear message instead of loading garbage. Nothing in the string is executable, so importing is safe.
-- Low-level encode / decode
local str = LuckyProfiles:Encode(MyAddonDB.profiles.current)
local data, err = LuckyProfiles:Decode(str)
if data then
-- apply data
else
print(err) -- e.g. "The share string is corrupted or incomplete."
end
-- Ready-made panels (built on LuckyUI)
LuckyProfiles:ShowExport("Export Profile", MyAddonDB.profiles.current)
LuckyProfiles:ShowImport("Import Profile", function(decoded)
MyAddonDB.profiles.current = decoded
MyAddon:Refresh()
end)
Export only the portable subset of your DB — functions and frame references are skipped automatically, but leaving them out keeps the string small.
LuckyItem
Item and spell data is not always ready the instant you ask for it. The name, icon, quality and link arrive from the server a moment later, so a row drawn immediately shows blanks that fill in on the next frame. LuckyItem hands you a callback that fires only once the data is present, and caches the result for the rest of the session.
-- Resolve one item; the callback fires immediately on a cache hit
LuckyItem:Get(19019, function(info)
if info then
print(info.icon, info.name, info.link, info.quality)
end
end)
-- Load a whole list, then render once with no per-row flicker
LuckyItem:GetMany({ 19019, 17182, 18803 }, function(results)
for id, info in pairs(results) do
AddRow(info) -- info is nil for any id that failed to load
end
end)
-- Synchronous render path: read what's already cached, never trigger a load
local info = LuckyItem:GetCached(19019)
if LuckyItem:IsCached(19019) then ... end
-- Spells work the same way
LuckyItem:GetSpell(2061, function(info)
if info then print(info.name, info.icon, info.castTime) end
end)
Item info fields: id, name, link, quality, icon, itemLevel, minLevel, type, subType, equipLoc, classID, subclassID, sellPrice, isReagent. Spell info fields: id, name, icon, castTime.
LuckyLog
local DevLog = LuckyLog:New("|cffc9a84cMyAddon|r:", function()
return MyAddonDB and MyAddonDB.devMode
end)
-- Prints only when devMode is true
DevLog("Player entered zone:", GetZoneText())
LuckyBugs
Nothing to wire up. Any Lua error raised inside a Lucky Phil addon is caught, de-duplicated, and offered up for reporting, whether or not the player has Lua errors turned on in the game's settings. Errors from other authors' addons are ignored and never stored.
The player sees a prompt asking whether they want to report it, at most three times per session and never during combat. Accepting opens a window with the Discord link and a copy-ready report: addon and library versions, game build, locale, the error, and the stack.
| Command | Action |
|---|---|
/luckybugs |
Show the captured errors and their reports |
/luckybugs on / /luckybugs off |
Turn the report prompt on or off |
-- Open the report window on the newest captured error
LuckyBugs:Show()
LuckyDeps
-- Check if an optional dependency is installed and enabled, before or after it loads
if LuckyDeps:IsEnabled("WeakAuras") then ... end
-- The same, at a minimum version read from its .toc
if LuckyDeps:IsEnabled("Luckys_Warbank_Stockist", "1.13.0") then ... end
-- Check if loaded and optionally at minimum version
local ok, err, status = LuckyDeps:Check("Details", "9.0.0")
if not ok then print(err) end
-- status is one of LuckyDeps.Status: OK, MISSING, DISABLED, OUTDATED.
-- DISABLED means installed but switched off, the one failure a player can undo.
if status == LuckyDeps.Status.DISABLED then
LuckyDeps:Enable("Details") -- also enables its own dependencies
ReloadUI()
end
A setting given a requires table greys its row out while the dependency is
unmet, and the About pane offers an Enable and Reload button when the
dependency is merely switched off.
LuckySound
-- Play an addon sound file
LuckySound:Play(LuckySound:Path("MyAddon", "sounds\\alert.ogg"))
-- Play a built-in WoW sound
LuckySound:PlayKit(SOUNDKIT.IG_QUEST_LOG_OPEN)
-- Stop a playing sound
local _, handle = LuckySound:Play(path)
LuckySound:Stop(handle)
A note on AI
My addons are made by one person who plays the game and wants them to work properly. I use AI tools to move faster, mostly on code, bug hunting, and docs, but every change is reviewed and tested in game before release. If a feature feels off or something breaks, that's mine to fix, and the Discord is the fastest way to reach me.
Author
Lucky Phil

