diff --git a/README.md b/README.md new file mode 100644 index 0000000..4efee8b --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Ade Alts + +Alt manager for OctoWoW (1.12 client). It remembers everything about all your characters, +including those on other accounts, and shows it in one window. + +- Profession cooldowns – Salt Shaker, Transmutes, Mooncloth; "ready" popup at login. +- Alt list – level, class icon, sorted by level or name. +- Inventory and bank of every alt, plus **item search** across all of them. +- Total gold of all alts. +- Professions – skill levels and known recipes (also searchable). +- Equipped gear of every alt (exclude cosmetic - tabard and shirt). +- Minimap button – docks into pfUI's addon button panel automatically. +- Settings – window scale, width, height, background opacity, background colour, cooldown-window opacity. + +## Requirements + +- OctoWoW / any 1.12 client. +- Optional but recommended: **[SuperWoW](https://github.com/balakethelock/SuperWoW)** – lets all accounts in the same game folder share data through `imports\AdeAlts.txt`. Without it, only cooldowns sync (guild / party / raid chat). +- Optional: pfUI (minimap button docking and skin). + +## Installation + +1. Put the `AdeAlts` folder into `\Interface\AddOns\` (the folder must be named `AdeAlts` and contain `AdeAlts.toc`). +2. Log in with each character once so it gets cached (cached only inside one client\game folder). +3. Updating: replace the whole folder. Saved data is kept (`WTF\...\SavedVariables\AdeAlts.lua`). + +## Usage + +Left-click the minimap button (or `/ade`) opens the alt manager. Right-click the button forces a sync. +The button tooltip lists only characters that have cooldowns. + +Open each profession window once per character (clear its filters first) so its recipes and cooldowns are recorded. +Fishing, Skinning and Herbalism have no recipes and need nothing. + +## Slash commands + +Every alias works the same: `/ade`, `/adealts`, `/pcd`, `/profcd`, `/cdt`. + +| Command | What it does | +|---|---| +| `/ade` (also `show`, `toggle`, `alts`) | Open / close the alt manager window | +| `/ade cd` (also `cooldowns`) | Open / close the profession cooldowns window | +| `/ade find ` | Open the manager and search items and recipes across all alts | +| `/ade list` | Print all cooldowns to the chat frame | +| `/ade sync` | Sync now: shared file (SuperWoW) and a chat request to guild/party/raid | +| `/ade scan` | Rescan this character (level, gold, bags, gear, professions) and print what the client reports | +| `/ade status` | Show whether SuperWoW file sync is on and how many characters are known | +| `/ade export` | Open a text box with all data (copy it to move data by hand) | +| `/ade import` | Open a text box to paste exported data into | +| `/ade minimap` | Show / hide the minimap button | +| `/ade alert` | Turn the login "cooldown ready" popup on / off | +| `/ade test` | Show the "ready" popup now (also when nothing is ready) | +| `/ade debug` | Print raw cooldown values of tracked bag items and stored cooldowns (for troubleshooting) | +| `/ade reset` | Set all cooldowns of the current character to Ready | +| `/ade del ` | Remove a cached character (not the one you are logged in on) | + +## How data is shared + +Each character stores its own record (gold, bags, bank, gear, professions, cooldowns). With SuperWoW all accounts +read and write one shared file, `\imports\AdeAlts.txt`, and the newest data per character wins. +Without SuperWoW, cooldowns are also exchanged through guild / party / raid addon messages. + +## Notes + +- Bank contents are updated when you open the bank. +- Salt Shaker can occur bug, use /ade scan if it's occur and report if /ade scan didn't help. +- Written for Lua 5.0 (1.12 client); the code is split into several files that share one global table `AdeAlts`. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md). diff --git a/core.lua b/core.lua new file mode 100644 index 0000000..914db85 --- /dev/null +++ b/core.lua @@ -0,0 +1,125 @@ +-- Ade Alts: core +AdeAlts = {} +local AA = AdeAlts + +-- AdeAlts : profession cooldown tracker for Turtle WoW / 1.12 clients (Lua 5.0) +-- Tracks Salt Shaker, Transmutes, Mooncloth (and any "Transmute: ..." recipe) per character. +-- Command: /pcd Minimap button: auto-docked by pfUI's addon button panel. + +AA.ADDON = "AdeAlts" +AA.VERSION = "1.0" +AA.PREFIX = "PCDT" +AA.DAY = 86400 +local HOUR = 3600 + +-- Fallback durations (seconds). The real remaining time is read from the recipe tooltip when possible. +AA.DEFAULTS = { + ["Mooncloth"] = 4 * AA.DAY, + ["Transmute: Arcanite"] = 2 * AA.DAY, + ["Transmute: Iron to Gold"] = 2 * AA.DAY, + ["Transmute: Mithril to Truesilver"] = 2 * AA.DAY, +} + +-- Items tracked through their bag cooldown. +AA.ITEMS = { + [15846] = { key = "Salt Shaker", cd = 3 * AA.DAY }, +} + +-- max possible cooldown per item key; anything beyond it is a bogus client value +local ITEMCD = {} +AA.ITEMNAME = {} +for id, it in pairs(AA.ITEMS) do ITEMCD[it.key] = it.cd; AA.ITEMNAME[string.lower(it.key)] = it end + +function AA.ClampRecord(c) + local changed = false + local lim = time() + for key, cd in pairs(ITEMCD) do + local e = c.cds and c.cds[key] + if e and e > lim + cd + 60 then + c.cds[key] = lim + cd + changed = true + end + end + return changed +end + +-- the hunter "Survival" talent tree is also a skill line (rank 1/1); the real profession has a max of 75+ +function AA.BadProf(name, maxr) + return name == "Survival" and (not maxr or maxr <= 1) +end + +-- professions without a recipe list (nothing to scan) +AA.NORECIPES = { Fishing = 1, Skinning = 1, Herbalism = 1 } + +AA.timers = {} +AA.prov = {} +local alerted = {} +AA.scanning, AA.dirty = false, false +AA.lastScan, AA.lastReply, AA.lastBroadcast = 0, 0, 0 +AA.bagDirty = false +AA.loaded = false +AA.fileDirty, AA.lastFileWrite = false, 0 +AA.LEGACY = "ProfCDTracker" +AA.FILE = "AdeAlts" -- \imports\AdeAlts.txt (SuperWoW) + +---------------------------------------------------------------------- +-- helpers +---------------------------------------------------------------------- +function AA.Print(m) + DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccAdeAlts|r: " .. m) +end + +function AA.After(sec, fn) + table.insert(AA.timers, { t = GetTime() + sec, fn = fn }) +end + +function AA.IsTracked(name) + return AA.DEFAULTS[name] or string.find(name, "^Transmute") +end + +function AA.FormatTime(s) + s = math.floor(s) + local d = math.floor(s / AA.DAY) + local h = math.floor(math.mod(s, AA.DAY) / HOUR) + local m = math.floor(math.mod(s, HOUR) / 60) + if d > 0 then return d .. "d " .. h .. "h" end + if h > 0 then return h .. "h " .. m .. "m" end + if m > 0 then return m .. "m" end + return s .. "s" +end + +function AA.ClassHex(class) + local col = RAID_CLASS_COLORS and class and RAID_CLASS_COLORS[class] + if col then + return string.format("%02x%02x%02x", math.floor(col.r * 255), math.floor(col.g * 255), math.floor(col.b * 255)) + end + return "ffffff" +end + +function AA.ParseDuration(text) + local total, found = 0, false + for n, u in string.gfind(text, "(%d+)%s*(%a+)") do + n = tonumber(n) + u = string.lower(u) + local fm + if string.find(u, "^d") then fm = AA.DAY + elseif string.find(u, "^h") then fm = HOUR + elseif string.find(u, "^m") then fm = 60 + elseif string.find(u, "^s") then fm = 1 end + if AA.f then total = total + n * AA.f; found = true end + end + if found then return total end + return nil +end + +function AA.SetCD(key, exp) + if not AA.me then return end + exp = math.floor(exp) + local old = AA.me.cds[key] + if old == nil or math.abs(old - exp) > 5 or ((old == 0) ~= (exp == 0)) then + AA.me.cds[key] = exp + AA.me.updated = time() + AA.dirty = true + end +end + diff --git a/events.lua b/events.lua new file mode 100644 index 0000000..153d261 --- /dev/null +++ b/events.lua @@ -0,0 +1,271 @@ +-- Ade Alts: events +local AA = AdeAlts + +---------------------------------------------------------------------- +-- driver: timers, ready alerts, delayed broadcast +---------------------------------------------------------------------- +local driver = CreateFrame("Frame") +local acc, filet, metat = 0, 0, 0 +driver:SetScript("OnUpdate", function() + acc = acc + arg1 + if acc < 1 then return end + acc = 0 + local now = GetTime() + + for i = table.getn(AA.timers), 1, -1 do + local t = AA.timers[i] + if t and t.t <= now then + table.remove(AA.timers, i) + t.fn() + end + end + + if not AA.me then return end + + if AA.bagDirty then + AA.bagDirty = false + AA.ScanBags() + AA.ScanSelf() + end + + if AA.fileDirty and now - AA.lastFileWrite > 10 then + AA.FileSync() + end + + -- ready alerts for the logged-in character + local nowt = time() + for key, exp in pairs(AA.me.cds) do + if exp > 0 and exp <= nowt then + AA.Print("|cff40ff40" .. key .. " is ready!|r") + AA.me.cds[key] = 0 + AA.me.updated = time() + AA.dirty = true + end + end + + metat = metat + 1 + if metat >= 30 then + metat = 0 + AA.ScanSelf() + end + + filet = filet + 1 + if filet >= 20 then + filet = 0 + AA.FileMerge() + end + + if AA.dirty and now - AA.lastBroadcast > 3 then + AA.dirty = false + AA.Broadcast() + AA.FileSync() + end +end) + +---------------------------------------------------------------------- +-- events +---------------------------------------------------------------------- +local ev = CreateFrame("Frame") +ev:RegisterEvent("ADDON_LOADED") +ev:RegisterEvent("PLAYER_ENTERING_WORLD") +ev:RegisterEvent("TRADE_SKILL_SHOW") +ev:RegisterEvent("TRADE_SKILL_UPDATE") +ev:RegisterEvent("BAG_UPDATE") +ev:RegisterEvent("BAG_UPDATE_COOLDOWN") +ev:RegisterEvent("BANKFRAME_OPENED") +ev:RegisterEvent("BANKFRAME_CLOSED") +ev:RegisterEvent("PLAYER_MONEY") +ev:RegisterEvent("PLAYER_LEVEL_UP") +ev:RegisterEvent("PLAYER_LOGOUT") +ev:RegisterEvent("UNIT_INVENTORY_CHANGED") +ev:RegisterEvent("SKILL_LINES_CHANGED") +ev:RegisterEvent("CRAFT_SHOW") +ev:RegisterEvent("CRAFT_UPDATE") +ev:RegisterEvent("CHAT_MSG_ADDON") + +ev:SetScript("OnEvent", function() + if event == "ADDON_LOADED" then + if arg1 == AA.ADDON then + if not AdeAltsDB then + -- migrate from the old ProfCD Tracker saved variables, if present + AdeAltsDB = ProfCDTrackerDB or {} + end + ProfCDTrackerDB = nil + AA.db = AdeAltsDB + AA.db.chars = AA.db.chars or {} + AA.db.items = AA.db.items or {} + -- drop the hunter Survival talent line that older versions stored as a profession + for _, ch in pairs(AA.db.chars) do + local sv = ch.prof and ch.prof.Survival + if sv and AA.BadProf("Survival", sv.m) then ch.prof.Survival = nil end + end + AA.ApplyConfig() + AA.db.angle = AA.db.angle or 200 + if AA.db.hidden then AA.btn:Hide() end + AA.PlaceButton() + end + + elseif event == "PLAYER_ENTERING_WORLD" then + if not AA.db then return end + if not AA.loaded then + AA.loaded = true + local name = UnitName("player") + local realm = GetRealmName() + local _, class = UnitClass("player") + AA.myKey = name .. "-" .. realm + AA.me = AA.db.chars[AA.myKey] + if not AA.me then + AA.me = { name = name, realm = realm, cds = {} } + AA.db.chars[AA.myKey] = AA.me + end + AA.me.class = class + AA.me.remote = nil + AA.me.updated = AA.me.updated or time() + if AA.ClampRecord(AA.me) then AA.me.updated = time(); AA.dirty = true end + AA.me.cds = AA.me.cds or {} + AA.StyleButton() + AA.enterTime = GetTime() + AA.After(2, function() AA.ScanBags(); AA.ScanSelf() end) + AA.After(5, function() AA.ScanSelf() end) + AA.After(8, function() AA.ScanSelf() end) + AA.After(13, function() AA.ScanSelf() end) + AA.After(20, function() AA.ScanSelf() end) + AA.After(4, AA.DockInPfUI) + AA.After(10, function() AA.Send("R"); AA.Broadcast() end) + AA.After(3, AA.FileSync) + AA.After(6, AA.ShowReadyAlert) + end + AA.bagDirty = true + + elseif event == "TRADE_SKILL_SHOW" or event == "TRADE_SKILL_UPDATE" then + AA.ScanTradeSkills() + + elseif event == "CRAFT_SHOW" or event == "CRAFT_UPDATE" then + AA.ScanCrafts() + + elseif event == "UNIT_INVENTORY_CHANGED" then + if arg1 == "player" then AA.bagDirty = true end + + elseif event == "SKILL_LINES_CHANGED" then + AA.bagDirty = true + + elseif event == "BANKFRAME_OPENED" then + AA.bankOpen = true + AA.bagDirty = true + elseif event == "BANKFRAME_CLOSED" then + AA.ScanSelf() + AA.bankOpen = false + elseif event == "PLAYER_MONEY" or event == "PLAYER_LEVEL_UP" then + AA.bagDirty = true + elseif event == "PLAYER_LOGOUT" then + if AA.me then AA.FileSync() end + elseif event == "BAG_UPDATE" or event == "BAG_UPDATE_COOLDOWN" then + AA.bagDirty = true + + elseif event == "CHAT_MSG_ADDON" then + if arg1 == AA.PREFIX and AA.db and arg4 ~= UnitName("player") then + if arg2 == "R" then + if AA.me and GetTime() - AA.lastReply > 5 then + AA.lastReply = GetTime() + AA.Broadcast() + end + else + AA.ReceiveRecord(arg2) + AA.Refresh() + end + end + end +end) + +---------------------------------------------------------------------- +-- slash commands +---------------------------------------------------------------------- +SLASH_PROFCD1 = "/pcd" +SLASH_PROFCD2 = "/profcd" +SLASH_PROFCD3 = "/cdt" +SLASH_PROFCD4 = "/ade" +SLASH_PROFCD5 = "/adealts" +SlashCmdList["PROFCD"] = function(msg) + msg = msg or "" + local _, _, cmd, arg = string.find(msg, "^%s*(%S*)%s*(.-)%s*$") + cmd = string.lower(cmd or "") + + if cmd == "" or cmd == "show" or cmd == "toggle" or cmd == "alts" then + AA.ToggleManager() + elseif cmd == "cd" or cmd == "cooldowns" then + AA.ToggleWindow() + elseif cmd == "find" and arg ~= "" then + AA.sbox:SetText(arg) + if not AA.M:IsVisible() then AA.M:Show() end + elseif cmd == "list" then + for _, e in ipairs(AA.Entries()) do AA.Print(e.l .. " " .. e.r) end + elseif cmd == "sync" then + AA.Send("R") + AA.Broadcast() + AA.FileSync() + AA.Print("synced (shared file: " .. ((ImportFile and ExportFile) and "on" or "off") .. ", chat request sent).") + elseif cmd == "scan" then + AA.Print("client reports: level=" .. tostring(UnitLevel("player")) .. " gold(copper)=" .. tostring(GetMoney())) + AA.ScanSelf() + AA.ScanBags(true) + AA.FileSync() + elseif cmd == "status" then + if ImportFile and ExportFile then + AA.Print("SuperWoW file sync: |cff40ff40ON|r (imports\\" .. AA.FILE .. ".txt, shared by all accounts in this game folder)") + else + AA.Print("SuperWoW file sync: |cffff6060OFF|r (SuperWoW not detected). Only guild/party/raid chat sync works.") + end + local n = 0 + for k in pairs(AA.db.chars) do n = n + 1 end + AA.Print(n .. " character(s) known.") + elseif cmd == "export" then + AA.ShowIO(true) + elseif cmd == "import" then + AA.ShowIO(false) + elseif cmd == "minimap" then + AA.db.hidden = not AA.db.hidden + if AA.db.hidden then AA.btn:Hide() else AA.btn:Show(); AA.After(1, AA.DockInPfUI) end + AA.Print("minimap button " .. (AA.db.hidden and "hidden" or "shown") .. ".") + elseif cmd == "alert" then + AA.db.noAlert = not AA.db.noAlert + AA.Print("login alert " .. (AA.db.noAlert and "disabled" or "enabled") .. ".") + elseif cmd == "test" then + AA.ShowReadyAlert(true) + elseif cmd == "debug" then + local found = 0 + for bag = 0, 4 do + for slot = 1, GetContainerNumSlots(bag) do + local link = GetContainerItemLink(bag, slot) + local it = AA.ItemFromLink(link) + if it then + found = found + 1 + local st, du = GetContainerItemCooldown(bag, slot) + AA.Print(it.key .. " in bag " .. bag .. " slot " .. slot .. ": start=" .. tostring(st) .. " duration=" .. tostring(du) .. " now=" .. GetTime() .. " remaining=" .. tostring((st or 0) + (du or 0) - GetTime())) + end + end + end + if found == 0 then AA.Print("no tracked item found in bags 0-4 (is it in the bank?)") end + for key, exp in pairs(AA.me.cds) do + AA.Print("stored " .. key .. ": " .. (exp > 0 and (AA.FormatTime(exp - time()) .. " left") or "ready")) + end + AA.Print("file sync: " .. ((ImportFile and ExportFile) and "on" or "off")) + elseif cmd == "reset" then + for k in pairs(AA.me.cds) do AA.me.cds[k] = 0 end + AA.dirty = true + AA.Print("cooldowns of this character reset to Ready.") + elseif cmd == "del" and arg ~= "" then + local a = string.lower(arg) + local n = 0 + for k, c in pairs(AA.db.chars) do + if k ~= AA.myKey and (string.lower(c.name) == a or string.lower(k) == a) then + AA.db.chars[k] = nil + n = n + 1 + end + end + AA.Print("removed " .. n .. " character(s).") + AA.Refresh() + else + AA.Print("/ade - alts window | cd (cooldowns) | find | list | sync | scan | status | debug | export | import | minimap | alert | reset | del ") + end +end + diff --git a/minimap.lua b/minimap.lua new file mode 100644 index 0000000..46099c8 --- /dev/null +++ b/minimap.lua @@ -0,0 +1,111 @@ +-- Ade Alts: minimap +local AA = AdeAlts + +---------------------------------------------------------------------- +-- minimap button (pfUI docks it automatically into its addon panel) +---------------------------------------------------------------------- +AA.btn = CreateFrame("Button", "AdeAltsMinimapButton", Minimap) +AA.btn:SetWidth(31) +AA.btn:SetHeight(31) +AA.btn:SetFrameStrata("MEDIUM") +AA.btn:SetFrameLevel(8) +AA.btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") +AA.btn:RegisterForDrag("LeftButton") + +local icon = AA.btn:CreateTexture(nil, "BACKGROUND") +icon:SetTexture("Interface\\Icons\\Trade_Alchemy") +local border = AA.btn:CreateTexture(nil, "OVERLAY") +border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder") + +function AA.StyleButton() + if pfUI and pfUI.api and pfUI.api.CreateBackdrop then + -- pfUI look: flat icon inside a pfUI backdrop, same as other docked buttons + if not AA.btn.pcdBackdrop then + AA.btn.pcdBackdrop = true + pcall(pfUI.api.CreateBackdrop, AA.btn, nil, true) + end + icon:SetDrawLayer("ARTWORK") + icon:ClearAllPoints() + icon:SetPoint("TOPLEFT", AA.btn, "TOPLEFT", 3, -3) + icon:SetPoint("BOTTOMRIGHT", AA.btn, "BOTTOMRIGHT", -3, 3) + icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + border:Hide() + AA.btn:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square") + elseif pfUI then + icon:SetDrawLayer("ARTWORK") + icon:ClearAllPoints() + icon:SetAllPoints(AA.btn) + icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + border:Hide() + AA.btn:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square") + else + icon:SetDrawLayer("BACKGROUND") + icon:ClearAllPoints() + icon:SetWidth(20); icon:SetHeight(20) + icon:SetPoint("TOPLEFT", AA.btn, "TOPLEFT", 6, -5) + icon:SetTexCoord(0, 1, 0, 1) + border:ClearAllPoints() + border:SetWidth(53); border:SetHeight(53) + border:SetPoint("TOPLEFT", AA.btn, "TOPLEFT", 0, 0) + border:Show() + AA.btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") + end +end +AA.StyleButton() + +function AA.PlaceButton() + if AA.btn:GetParent() ~= Minimap then return end -- docked by pfUI + local angle = (AA.db and AA.db.angle) or 200 + local r = Minimap:GetWidth() / 2 + 6 + local rad = math.rad(angle) + AA.btn:ClearAllPoints() + AA.btn:SetPoint("CENTER", Minimap, "CENTER", math.cos(rad) * r, math.sin(rad) * r) +end +AA.PlaceButton() + +AA.btn:SetScript("OnDragStart", function() + if this:GetParent() ~= Minimap then return end + this:SetScript("OnUpdate", function() + local mx, my = GetCursorPosition() + local sc = Minimap:GetEffectiveScale() + local cx, cy = Minimap:GetCenter() + AA.db.angle = math.deg(math.atan2(my / sc - cy, mx / sc - cx)) + AA.PlaceButton() + end) +end) +AA.btn:SetScript("OnDragStop", function() this:SetScript("OnUpdate", nil) end) + +AA.btn:SetScript("OnClick", function() + if arg1 == "RightButton" then + AA.Send("R") + AA.Broadcast() + AA.FileSync() + AA.Print("synced.") + else + AA.ToggleManager() + end +end) + +AA.btn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_LEFT") + GameTooltip:AddLine("Ade Alts - cooldowns") + if AA.db then + local list = AA.Entries(true) + if table.getn(list) == 0 then + GameTooltip:AddLine("|cff808080No cooldowns recorded yet|r") + end + for _, e in ipairs(list) do + GameTooltip:AddDoubleLine(e.l, e.r, 1, 1, 1, 1, 1, 1) + end + end + GameTooltip:AddLine("|cff808080Left: window Right: sync Drag: move|r") + GameTooltip:Show() +end) +AA.btn:SetScript("OnLeave", function() GameTooltip:Hide() end) + +function AA.DockInPfUI() + if pfUI and pfUI.addonbuttons and pfUI.addonbuttons.ProcessButtons then + pcall(function() pfUI.addonbuttons:ProcessButtons() end) + end +end + diff --git a/scan.lua b/scan.lua new file mode 100644 index 0000000..9b9c91b --- /dev/null +++ b/scan.lua @@ -0,0 +1,448 @@ +-- Ade Alts: scan +local AA = AdeAlts + +---------------------------------------------------------------------- +-- scanning: trade skill window +---------------------------------------------------------------------- +local tip = CreateFrame("GameTooltip", "AdeAltsTip", nil, "GameTooltipTemplate") + +local function TipCooldown(index) + if GetTradeSkillCooldown then + local c = GetTradeSkillCooldown(index) + if c and c > 0 then return c end + end + tip:SetOwner(UIParent, "ANCHOR_NONE") + tip:ClearLines() + tip:SetTradeSkillItem(index) + for i = 1, tip:NumLines() do + local fs = getglobal("AdeAltsTipTextLeft" .. i) + local t = fs and fs:GetText() + if t then + local l = string.lower(t) + if string.find(l, "cooldown") and string.find(l, "remaining") then + local d = AA.ParseDuration(t) + if d then return d end + end + end + end + return nil +end + +-- remember the known recipes of a profession (read from its trade skill / craft window) +local function StoreRecipes(prof, recs, rank, maxrank) + if not AA.me or not prof or prof == "" or table.getn(recs) == 0 then return end + AA.me.prof = AA.me.prof or {} + local p = AA.me.prof[prof] + if not p then + p = {} + AA.me.prof[prof] = p + end + if rank and rank > 0 and (p.r ~= rank or p.m ~= maxrank) then + p.r = rank + p.m = maxrank + p.ts = time() + AA.fileDirty = true + end + table.sort(recs) + local sig = table.concat(recs, "~") + if sig ~= p.sig then + p.rec = recs + p.sig = sig + p.rts = time() + AA.fileDirty = true + end +end + +function AA.ScanTradeSkills() + if AA.scanning or not AA.me then return end + if GetTime() - AA.lastScan < 1 then return end + local n = GetNumTradeSkills() + if not n or n == 0 then return end + AA.scanning = true + + -- expand collapsed headers temporarily so every recipe is visible + local expanded = {} + for i = n, 1, -1 do + local name, ttype, _, isExp = GetTradeSkillInfo(i) + if ttype == "header" and not isExp then + expanded[name] = true + ExpandTradeSkillSubClass(i) + end + end + + n = GetNumTradeSkills() + local nowt = time() + local recs = {} + for i = 1, n do + local name, ttype = GetTradeSkillInfo(i) + if name and ttype ~= "header" then table.insert(recs, name) end + if name and ttype ~= "header" and AA.IsTracked(name) then + local rem = TipCooldown(i) + if rem then + AA.db.tipWorks = true + AA.SetCD(name, nowt + rem) + else + local cur = AA.me.cds[name] + if AA.prov[name] and nowt - AA.prov[name] < 10 then + -- just crafted, keep provisional value + elseif cur and cur > nowt and not AA.db.tipWorks then + -- tooltips don't expose cooldowns on this client: keep the estimate + else + AA.SetCD(name, 0) + end + end + end + end + + local pn, pr, pm = GetTradeSkillLine() + StoreRecipes(pn, recs, pr, pm) + + -- restore collapsed state + for i = GetNumTradeSkills(), 1, -1 do + local name, ttype, _, isExp = GetTradeSkillInfo(i) + if ttype == "header" and expanded[name] and isExp then + CollapseTradeSkillSubClass(i) + end + end + + AA.scanning = false + AA.lastScan = GetTime() + if AA.Refresh then AA.Refresh() end +end + +-- craft window (Enchanting uses it instead of the trade skill window) +local scanningCraft, lastCraftScan = false, 0 +function AA.ScanCrafts() + if scanningCraft or not AA.me or not GetNumCrafts then return end + if GetTime() - lastCraftScan < 1 then return end + local n = GetNumCrafts() + if not n or n == 0 then return end + local pn, pr, pm + if GetCraftDisplaySkillLine then pn, pr, pm = GetCraftDisplaySkillLine() end + if not pn or pn == "" then return end -- e.g. hunter pet training + scanningCraft = true + if ExpandCraftSkillLine then + for i = n, 1, -1 do + local name, sub, ctype, _, isExp = GetCraftInfo(i) + if ctype == "header" and not isExp then ExpandCraftSkillLine(i) end + end + end + n = GetNumCrafts() + local recs = {} + for i = 1, n do + local name, sub, ctype = GetCraftInfo(i) + if name and ctype ~= "header" then table.insert(recs, name) end + end + StoreRecipes(pn, recs, pr, pm) + scanningCraft = false + lastCraftScan = GetTime() + if AA.Refresh then AA.Refresh() end +end + +-- crafting hook: provisional cooldown the moment a tracked recipe is started +local origDoTradeSkill = DoTradeSkill +DoTradeSkill = function(index, count) + if AA.me and index then + local name, ttype, avail = GetTradeSkillInfo(index) + if name and avail and avail > 0 and AA.IsTracked(name) then + local d = AA.DEFAULTS[name] + if d then + AA.SetCD(name, time() + d) + AA.prov[name] = time() + end + AA.After(5, function() if TradeSkillFrame and TradeSkillFrame:IsVisible() then AA.ScanTradeSkills() end end) + AA.After(12, function() if TradeSkillFrame and TradeSkillFrame:IsVisible() then AA.ScanTradeSkills() end end) + end + end + return origDoTradeSkill(index, count) +end + +---------------------------------------------------------------------- +-- scanning: bags (Salt Shaker etc.) +---------------------------------------------------------------------- +local BYNAME = {} +for id, it in pairs(AA.ITEMS) do BYNAME[it.key] = it end + +-- match a tracked item by id, or by name as a fallback (in case the item id differs on this server) +function AA.ItemFromLink(link) + if not link then return nil end + local _, _, id = string.find(link, "item:(%d+)") + local it = id and AA.ITEMS[tonumber(id)] + if not it then + local _, _, nm = string.find(link, "%[(.-)%]") + if nm then it = BYNAME[nm] end + end + return it +end + +AA.bankOpen = false + +local function FindItemDef(link) + local _, _, id = string.find(link, "item:(%d+)") + local it = id and AA.ITEMS[tonumber(id)] + if it then return it end + local _, _, nm = string.find(link, "%[(.-)%]") + if nm and AA.ITEMNAME[string.lower(nm)] then return AA.ITEMNAME[string.lower(nm)] end + return nil +end + +function AA.ScanBags(verbose) + if not AA.me then return end + local bags = { 0, 1, 2, 3, 4 } + if AA.bankOpen then + table.insert(bags, -1) + for b = 5, 10 do table.insert(bags, b) end + end + local found = 0 + for _, bag in ipairs(bags) do + local slots = GetContainerNumSlots(bag) or 0 + for slot = 1, slots do + local link = GetContainerItemLink(bag, slot) + if link then + local it = FindItemDef(link) + if it then + found = found + 1 + local start, dur = GetContainerItemCooldown(bag, slot) + local exp = 0 + if start and start > 0 and dur and dur > 30 then + local rem = start + dur - GetTime() + if rem > 0 then + exp = time() + rem + if exp > time() + it.cd + 60 then + -- client reported a nonsense start time: keep our stored value if sane, else assume a fresh use + local cur = AA.me.cds[it.key] + if cur and cur > time() and cur <= time() + it.cd + 60 then exp = cur else exp = time() + it.cd end + end + end + end + if verbose then + AA.Print(it.key .. " in bag " .. bag .. " slot " .. slot .. ": start=" .. tostring(start) .. " dur=" .. tostring(dur) .. " -> " .. (exp > 0 and AA.FormatTime(exp - time()) or "Ready")) + end + AA.SetCD(it.key, exp) + end + end + end + end + if verbose and found == 0 then + AA.Print("no tracked items found in bags" .. (AA.bankOpen and " / bank" or "") .. ". (Open the bank to scan it too.)") + end + return found +end + +---------------------------------------------------------------------- +-- scanning: character data, inventory and bank (for the alts manager) +---------------------------------------------------------------------- +local QHEX = { ["9d9d9d"] = 0, ["ffffff"] = 1, ["1eff00"] = 2, ["0070dd"] = 3, ["a335ee"] = 4, ["ff8000"] = 5 } + +local function LearnItem(id, link, tex) + AA.db.items = AA.db.items or {} + local it = AA.db.items[id] + if it and it.n and it.t and it.t ~= "" then return end + local _, _, nm = string.find(link, "%[(.-)%]") + local _, _, col = string.find(link, "|c%x%x(%x%x%x%x%x%x)|H") + local t = tex + if t then t = (string.gsub(t, "^Interface\\Icons\\", "")) end + AA.db.items[id] = { n = nm or ("item:" .. id), t = t or "", q = (col and QHEX[col]) or 1 } +end + +-- returns aggregated list {{id,count},...} and a signature string, or nil if the containers are not available +local function ScanContainers(bags) + local order, agg = {}, {} + local slotsTotal = 0 + for _, bag in ipairs(bags) do + local slots = GetContainerNumSlots(bag) or 0 + slotsTotal = slotsTotal + slots + for slot = 1, slots do + local link = GetContainerItemLink(bag, slot) + if link then + local _, _, sid = string.find(link, "item:(%d+)") + local id = tonumber(sid) + if id then + local tex, count = GetContainerItemInfo(bag, slot) + count = count or 1 + if count < 1 then count = 1 end + if not agg[id] then + agg[id] = 0 + table.insert(order, id) + end + agg[id] = agg[id] + count + LearnItem(id, link, tex) + end + end + end + end + if slotsTotal == 0 then return nil end + local list, parts = {}, {} + for _, id in ipairs(order) do + table.insert(list, { id, agg[id] }) + table.insert(parts, id .. ":" .. agg[id]) + end + return list, table.concat(parts, "~") +end + +-- equipped gear: slot -> "id:enchant:suffix:unique" +local function ScanEquip() + if not AA.me then return false end + local eq, parts, any = {}, {}, false + for slot = 0, 19 do + local link = GetInventoryItemLink("player", slot) + if link then + local _, _, ref = string.find(link, "item:(%-?%d+:%-?%d+:%-?%d+:%-?%d+)") + local _, _, sid = string.find(link, "item:(%d+)") + if ref and sid then + eq[slot] = ref + table.insert(parts, slot .. "=" .. ref) + LearnItem(tonumber(sid), link, GetInventoryItemTexture("player", slot)) + any = true + end + end + end + if not any then return false end -- gear not loaded yet: keep what we have + local sig = table.concat(parts, "~") + if sig ~= AA.me.eqSig then + AA.me.equip = eq + AA.me.eqSig = sig + AA.me.eqTs = time() + return true + end + return false +end + +-- profession skill levels from the skill window +local PROFN = { + ["Alchemy"] = 1, ["Blacksmithing"] = 1, ["Enchanting"] = 1, ["Engineering"] = 1, ["Herbalism"] = 1, + ["Leatherworking"] = 1, ["Mining"] = 1, ["Skinning"] = 1, ["Tailoring"] = 1, + ["Cooking"] = 1, ["First Aid"] = 1, ["Fishing"] = 1, ["Survival"] = 1, +} +local function ScanSkills() + if not AA.me or not GetNumSkillLines then return false end + local n = GetNumSkillLines() + if not n or n == 0 then return false end + -- collapsed headers hide their skills: expand them (this only happens once) + for i = n, 1, -1 do + local name, header, isExp = GetSkillLineInfo(i) + if header and not isExp then ExpandSkillHeader(i) end + end + n = GetNumSkillLines() + AA.me.prof = AA.me.prof or {} + local seen, changed = {}, false + local section + for i = 1, n do + local name, header, _, rank, _, _, maxRank = GetSkillLineInfo(i) + if header then section = name end + if not header and name and PROFN[name] and not AA.BadProf(name, maxRank) + and not (name == "Survival" and section == "Class Skills") then + seen[name] = true + local p = AA.me.prof[name] + if not p then + p = {} + AA.me.prof[name] = p + end + if p.r ~= rank or p.m ~= maxRank then + p.r = rank + p.m = maxRank + p.ts = time() + changed = true + end + end + end + for name, p in pairs(AA.me.prof) do + if not seen[name] and PROFN[name] then + AA.me.prof[name] = nil -- profession was unlearned + changed = true + end + end + return changed +end + +AA.enterTime = 0 + +function AA.ScanSelf() + if not AA.me or not AA.db then return end + local changed = false + local lvl, gold = UnitLevel("player"), GetMoney() + local early = GetTime() - AA.enterTime < 12 -- right after login the client may still report 0 + local metaChanged = false + if lvl and lvl > 0 and AA.me.level ~= lvl then + AA.me.level = lvl + metaChanged = true + end + if gold and not (gold == 0 and early) and AA.me.gold ~= gold then + AA.me.gold = gold + metaChanged = true + end + if metaChanged then + AA.me.dataTs = time() + changed = true + end + if ScanEquip() then changed = true end + if ScanSkills() then changed = true end + local list, sig = ScanContainers({ 0, 1, 2, 3, 4 }) + if list and sig ~= AA.me.invSig then + AA.me.inv = list + AA.me.invSig = sig + AA.me.invTs = time() + changed = true + end + if AA.bankOpen then + local bags = { -1 } + for b = 5, 10 do table.insert(bags, b) end + local bl, bs = ScanContainers(bags) + if bl and bs ~= AA.me.bankSig then + AA.me.bank = bl + AA.me.bankSig = bs + AA.me.bankTs = time() + changed = true + end + end + if changed then + AA.fileDirty = true + if AA.RefreshManager then AA.RefreshManager() end + end +end + +---------------------------------------------------------------------- +-- data -> display +---------------------------------------------------------------------- +function AA.SortedChars() + local list = {} + for k, c in pairs(AA.db.chars) do table.insert(list, k) end + table.sort(list, function(a, b) + if a == AA.myKey then return true end + if b == AA.myKey then return false end + return a < b + end) + return list +end + +function AA.Entries(onlyCd) + local out = {} + local nowt = time() + for _, k in ipairs(AA.SortedChars()) do + local c = AA.db.chars[k] + local keys = {} + for key, exp in pairs(c.cds or {}) do table.insert(keys, key) end + table.sort(keys) + if table.getn(keys) > 0 or not onlyCd then + table.insert(out, { l = "|cff" .. AA.ClassHex(c.class) .. c.name .. "|r |cff808080" .. c.realm .. "|r", r = "" }) + if table.getn(keys) == 0 then + table.insert(out, { l = " |cff808080(no cooldowns recorded)|r", r = "" }) + end + for _, key in ipairs(keys) do + local exp = c.cds[key] + local rem = exp - nowt + local r + if exp <= 0 or rem <= 0 then + r = "|cff40ff40Ready|r" + elseif rem >= AA.DAY then + r = "|cffff6060" .. AA.FormatTime(rem) .. "|r" + else + r = "|cffffd100" .. AA.FormatTime(rem) .. "|r" + end + table.insert(out, { l = " " .. key, r = r }) + end + end + end + return out +end +