-- RelationshipsAttune: Core.lua -- Event handling, scanning, throttled auto-scan, faction-aware evaluation. -- Lua 5.0 / WoW 1.12 safe (Turtle/Octo compatible). RelationshipsAttune = RelationshipsAttune or {} local RA = RelationshipsAttune local L = RelationshipsAttune_L RA.VERSION = "1.2.7" RA.COMM_PREFIX = "RATTUNE" RA.playerName = nil RA.playerRealm = nil RA.playerKey = nil RA.playerFaction = nil RA.initialized = false -- --------------------------------------------------------------- -- Utilities -- --------------------------------------------------------------- local function tsize(t) if not t then return 0 end local n = 0 for _ in pairs(t) do n = n + 1 end return n end RA.tsize = tsize local function print(msg) if DEFAULT_CHAT_FRAME then DEFAULT_CHAT_FRAME:AddMessage("|cff66ccff[RAttune]|r " .. tostring(msg)) end end RA.print = print local STANDING = { Hated = 1, Hostile = 2, Unfriendly = 3, Neutral = 4, Friendly = 5, Honored = 6, Revered = 7, Exalted = 8, } local DATA_STANDING_MAP = { [1]=2, [2]=3, [3]=4, [4]=5, [5]=6, [6]=7, [7]=8 } -- Return true if this step is applicable to the current player faction. function RA:StepApplies(step) if step.faction and step.faction ~= "Both" and step.faction ~= RA.playerFaction then return false end return true end -- --------------------------------------------------------------- -- Saved-variables init -- --------------------------------------------------------------- function RA:InitDB() if not RelationshipsAttuneDB then RelationshipsAttuneDB = {} end if not RelationshipsAttuneDB.players then RelationshipsAttuneDB.players = {} end if not RelationshipsAttuneDB.options then RelationshipsAttuneDB.options = {} end local o = RelationshipsAttuneDB.options if o.autoShare == nil then o.autoShare = true end if o.autoScanSeconds == nil then o.autoScanSeconds = 20 end if type(o.minimapAngle) ~= "number" then o.minimapAngle = 200 end if type(o.minimapRadius) ~= "number" then o.minimapRadius = 80 end if o.minimapHide == nil then o.minimapHide = false end if not RelationshipsAttuneCharDB then RelationshipsAttuneCharDB = {} end if not RelationshipsAttuneCharDB.quests then RelationshipsAttuneCharDB.quests = {} end if not RelationshipsAttuneCharDB.items then RelationshipsAttuneCharDB.items = {} end if not RelationshipsAttuneCharDB.completedAttunements then RelationshipsAttuneCharDB.completedAttunements = {} end if not RelationshipsAttuneCharDB.progress then RelationshipsAttuneCharDB.progress = {} end if not RelationshipsAttuneCharDB.expanded then RelationshipsAttuneCharDB.expanded = {} end end -- --------------------------------------------------------------- -- Quest log scan (vanilla API) -- --------------------------------------------------------------- function RA:ScanQuestLog() if not GetNumQuestLogEntries then return end local numEntries = GetNumQuestLogEntries() for i = 1, numEntries do local title, level, tag, isHeader, _, isComplete, _, questId = GetQuestLogTitle(i) if (not isHeader) and questId then if isComplete and isComplete == 1 then RelationshipsAttuneCharDB.quests[questId] = "complete" else if not RelationshipsAttuneCharDB.quests[questId] then RelationshipsAttuneCharDB.quests[questId] = "inlog" end end end end end function RA:MarkQuestTurnedIn(questId) if not questId then return end RelationshipsAttuneCharDB.quests[questId] = "turnedin" self:RecomputeAll() if RA.UI and RA.UI.Refresh then RA.UI:Refresh() end end -- --------------------------------------------------------------- -- Item scan: bags, equipped slots, and keyring -- --------------------------------------------------------------- local function rememberItemLink(link) if not link then return end local _, _, idStr = string.find(link, "item:(%d+)") local linkId if idStr then linkId = tonumber(idStr) if linkId then RelationshipsAttuneCharDB.items[linkId] = true end end -- Resolve by name so a custom Turtle/Octo item whose live client id -- differs from the id declared in Data.lua still gets credited. -- Try GetItemInfo first (works when the item is cached), then fall back -- to parsing the bracketed display name straight out of the link. On -- 1.12 / Turtle / Octo, keyring items very often return a link but -- GetItemInfo returns nil because the item is not cached client-side, -- so the bracketed-name parse is what actually credits the item. local resolvedName if GetItemInfo then local ok, n = pcall(GetItemInfo, link) if ok and n then resolvedName = n end end if not resolvedName then local _, _, bracket = string.find(link, "%[(.-)%]") if bracket and bracket ~= "" then resolvedName = bracket end end if resolvedName then RA:BuildNameIndex() if RA._nameToId then local mapped = RA._nameToId[string.lower(resolvedName)] if mapped and mapped ~= linkId then RelationshipsAttuneCharDB.items[mapped] = true end end end end local function rememberItemId(id) if not id then return end id = tonumber(id) if id then RelationshipsAttuneCharDB.items[id] = true end end -- Build (and cache) a name -> item id map from RelationshipsAttune_Data so we -- can identify keyring items even when the client refuses to return a link -- for them (a known 1.12 / Turtle / Octo quirk for keyring slots). RA._nameToId = nil function RA:BuildNameIndex() if RA._nameToId then return RA._nameToId end local map = {} if RelationshipsAttune_Data then for i = 1, table.getn(RelationshipsAttune_Data) do local att = RelationshipsAttune_Data[i] if att and att.steps then for j = 1, table.getn(att.steps) do local s = att.steps[j] if s and s.type == "item" and s.name and s.id then map[string.lower(s.name)] = s.id end end end end end RA._nameToId = map return map end -- Hidden tooltip used to force item info to resolve for keyring slots. local _raTip local _raTipName = "RelationshipsAttuneScanTip" local function ensureTip() if _raTip then return _raTip end _raTip = CreateFrame("GameTooltip", _raTipName, nil, "GameTooltipTemplate") _raTip:SetOwner(UIParent, "ANCHOR_NONE") return _raTip end -- Read the first tooltip line (item name) after a SetBagItem call. -- 1.12 exposes frame globals via getglobal(), not _G. local function tipItemName() local fs if getglobal then fs = getglobal(_raTipName .. "TextLeft1") end if (not fs) and _G then fs = _G[_raTipName .. "TextLeft1"] end if fs and fs.GetText then local t = fs:GetText() if t and t ~= "" then return t end end return nil end local function scanContainer(containerId) if not GetContainerNumSlots then return end local ok, slots = pcall(GetContainerNumSlots, containerId) if not ok or not slots or slots < 1 then return end for slot = 1, slots do local ok2, link = pcall(GetContainerItemLink, containerId, slot) if ok2 then rememberItemLink(link) end end end -- Scan a keyring-like container. Uses every fallback we know of so that -- keys register on vanilla 1.12, Turtle, and Octo regardless of which -- bag id / API path actually works on that client build: -- 1) GetContainerItemLink -> parse item id + bracketed name from link. -- 2) SetBagItem tooltip -> read visible line as name. -- 3) GetContainerItemInfo -> at minimum proves a slot is occupied so -- we keep probing even when NumSlots lies. local function scanKeyringContainer(bag) if not GetContainerNumSlots then return end local ok, slots = pcall(GetContainerNumSlots, bag) if not ok or not slots then slots = 0 end -- Some 1.12 clients report 0 slots for the keyring until it has been -- opened at least once. Probe a reasonable upper bound in that case. local probe = slots if probe < 1 then probe = 32 end local tip = ensureTip() local nameMap = RA:BuildNameIndex() for slot = 1, probe do pcall(function() tip:ClearLines() end) local setOk = false pcall(function() tip:SetBagItem(bag, slot); setOk = true end) -- Path 1: link local link local okL, l = pcall(GetContainerItemLink, bag, slot) if okL then link = l end if link then rememberItemLink(link) end -- Path 2: tooltip visible name (works even when link is nil). if setOk then local name = tipItemName() if name and nameMap then local id = nameMap[string.lower(name)] if id then rememberItemId(id) end end end -- Path 3: GetContainerItemInfo. We can't get a name from it, but -- if it returns a texture the slot is definitely occupied; that -- lets us know it's worth continuing to probe even when the -- earlier paths returned nothing on this build. -- (No-op for identification, kept for future diagnostics.) if GetContainerItemInfo then pcall(GetContainerItemInfo, bag, slot) end end pcall(function() tip:Hide() end) end -- Warm the client-side item cache for every item id we care about. On -- vanilla 1.12 the first GetItemInfo(id) call for an uncached item kicks -- off a server fetch and returns nil; a later call returns real data. -- Doing this at load time (and again after a short delay) means that by -- the time we parse a keyring link, GetItemInfo can actually resolve it. function RA:WarmItemCache() if not GetItemInfo or not RelationshipsAttune_Data then return end for i = 1, table.getn(RelationshipsAttune_Data) do local att = RelationshipsAttune_Data[i] if att and att.steps then for j = 1, table.getn(att.steps) do local s = att.steps[j] if s and s.type == "item" and s.id then pcall(GetItemInfo, s.id) end end end end end function RA:ScanBags() if not GetContainerNumSlots then return end RA:BuildNameIndex() for bag = 0, 4 do scanContainer(bag) end -- Keyring: try every index the various 1.12 forks use. Vanilla/Turtle/ -- Octo typically expose it as -2; a few builds use -1, -3, or define -- KEYRING_CONTAINER to something else entirely. Scanning a bogus id -- is harmless because scanKeyringContainer pcall-guards every call. local seen = {} local function tryBag(b) if b == nil or seen[b] then return end seen[b] = true scanKeyringContainer(b) end tryBag(-2) tryBag(-1) tryBag(-3) tryBag(KEYRING_CONTAINER) if GetInventoryItemLink then for slot = 1, 19 do local ok, link = pcall(GetInventoryItemLink, "player", slot) if ok then rememberItemLink(link) end end end end -- Back-compat shim: older code paths may still call this. function RA:ForceKeyringResolve() scanKeyringContainer(-2) end -- --------------------------------------------------------------- -- Reputation lookup -- --------------------------------------------------------------- function RA:GetReputationStanding(factionName) if not GetNumFactions then return 0 end local numFactions = GetNumFactions() for i = 1, numFactions do local name, _, standingId, _, _, _, _, _, isHeader = GetFactionInfo(i) if name == factionName and not isHeader then return standingId or 0 end end return 0 end -- --------------------------------------------------------------- -- Compute attunement progress -- --------------------------------------------------------------- function RA:EvaluateStep(step) if step.type == "level" then return (UnitLevel("player") or 0) >= (step.level or 0) elseif step.type == "quest" then local st = RelationshipsAttuneCharDB.quests[step.id] return st == "complete" or st == "turnedin" elseif step.type == "item" then return RelationshipsAttuneCharDB.items[step.id] == true elseif step.type == "rep" then local need = DATA_STANDING_MAP[step.standing] or step.standing or 5 local have = self:GetReputationStanding(step.faction) return have >= need end return false end function RA:EvaluateAttunement(att) if att.faction and att.faction ~= "Both" and att.faction ~= RA.playerFaction then return { applicable = false, done = false, steps = {}, doneCount = 0, total = 0 } end local steps, doneCount, total = {}, 0, 0 local numSteps = table.getn(att.steps) for i = 1, numSteps do local step = att.steps[i] if self:StepApplies(step) then local ok = self:EvaluateStep(step) and true or false steps[i] = ok total = total + 1 if ok then doneCount = doneCount + 1 end else steps[i] = nil end end -- Short-circuit: if the player already owns the final key/item (or a step -- explicitly flagged final=true) then the entire attunement is done, even -- if earlier bookkeeping steps (prereq quests, reputation) can no longer -- be detected on this character. This handles the case where the key was -- earned before the addon was installed. local finalIdx = nil for i = numSteps, 1, -1 do local step = att.steps[i] if step.final and self:StepApplies(step) then finalIdx = i break end end if not finalIdx then -- Fall back to the last applicable item step (typically the key). for i = numSteps, 1, -1 do local step = att.steps[i] if step.type == "item" and self:StepApplies(step) then finalIdx = i break end end end if not finalIdx then -- If there is no key/item step, the last applicable step is the final -- proof we can see. This covers quest-only or reputation attunements. for i = numSteps, 1, -1 do local step = att.steps[i] if self:StepApplies(step) then finalIdx = i break end end end local persistedDone = RelationshipsAttuneCharDB.completedAttunements and RelationshipsAttuneCharDB.completedAttunements[att.id] if persistedDone or (finalIdx and steps[finalIdx] == true) then for i = 1, numSteps do if steps[i] == false then steps[i] = true doneCount = doneCount + 1 end end end if total > 0 and doneCount == total then RelationshipsAttuneCharDB.completedAttunements[att.id] = true end return { applicable = true, done = (total > 0 and doneCount == total), steps = steps, doneCount = doneCount, total = total, } end function RA:RecomputeAll() for i = 1, table.getn(RelationshipsAttune_Data) do local att = RelationshipsAttune_Data[i] RelationshipsAttuneCharDB.progress[att.id] = self:EvaluateAttunement(att) end RelationshipsAttuneDB.players[RA.playerKey] = { name = RA.playerName, realm = RA.playerRealm, faction = RA.playerFaction, level = UnitLevel("player") or 0, class = UnitClass("player"), version = RA.VERSION, updated = time and time() or 0, progress = RelationshipsAttuneCharDB.progress, } end -- --------------------------------------------------------------- -- Throttled auto-scan -- --------------------------------------------------------------- RA._throttle = 0 RA._dirty = false RA._minRescan = 2.0 function RA:MarkDirty() RA._dirty = true end function RA:_OnUpdate(elapsed) RA._throttle = RA._throttle + (elapsed or 0) local interval = (RelationshipsAttuneDB and RelationshipsAttuneDB.options and RelationshipsAttuneDB.options.autoScanSeconds) or 20 if interval < 5 then interval = 5 end -- Delayed post-login retry passes. GetItemInfo populates async on -- 1.12, and the keyring itself may not be populated at PLAYER_LOGIN, -- so we re-warm the cache and rescan a couple of times shortly after -- login to catch keys that weren't resolvable on the first pass. if RA._retryAt and GetTime then local now = GetTime() local pending = {} local fired = false for i = 1, table.getn(RA._retryAt) do local t = RA._retryAt[i] if now >= t then fired = true else table.insert(pending, t) end end if fired then RA:WarmItemCache() RA:ScanBags() RA:RecomputeAll() if RA.UI and RA.UI.Refresh then RA.UI:Refresh() end end if table.getn(pending) == 0 then RA._retryAt = nil else RA._retryAt = pending end end if RA._dirty and RA._throttle >= RA._minRescan then RA._dirty = false RA._throttle = 0 RA:ScanQuestLog() RA:ScanBags() RA:RecomputeAll() if RA.UI and RA.UI.Refresh then RA.UI:Refresh() end return end if RA._throttle >= interval then RA._throttle = 0 RA:ScanQuestLog() RA:ScanBags() RA:RecomputeAll() if RA.UI and RA.UI.Refresh then RA.UI:Refresh() end end end -- --------------------------------------------------------------- -- Slash commands -- --------------------------------------------------------------- local function slashHandler(msg) msg = string.lower(msg or "") -- trim msg = string.gsub(msg, "^%s+", "") msg = string.gsub(msg, "%s+$", "") if msg == "show" then if RA.UI then RA.UI:Show() end elseif msg == "hide" then -- If the window is open, /attune hide closes it; otherwise it hides the minimap button. if RA.UI and RA.UI:IsShown() then RA.UI:Hide() elseif RA.Minimap then RA.Minimap:Hide() print("Minimap button hidden. Type /attune minimap to show it again.") end elseif msg == "minimap" or msg == "mm" then if RA.Minimap then RA.Minimap:Toggle() local o = RelationshipsAttuneDB.options if o.minimapHide then print("Minimap button hidden. /attune minimap to show it.") else print("Minimap button shown. Drag to reposition around the minimap.") end end elseif msg == "sync" then if RA.Comm then RA.Comm:RequestSync() end print(L.MSG_SYNC_SENT) elseif msg == "scan" then RA:ScanQuestLog(); RA:ScanBags(); RA:RecomputeAll() if RA.UI and RA.UI.Refresh then RA.UI:Refresh() end print(L.MSG_SCANNED) elseif msg == "reset" then RelationshipsAttuneCharDB = nil RA:InitDB() RA:ScanQuestLog(); RA:ScanBags(); RA:RecomputeAll() if RA.UI and RA.UI.Refresh then RA.UI:Refresh() end print(L.MSG_RESET) elseif msg == "debug" or string.find(msg, "^find") then local _, _, arg = string.find(msg, "find%s+(%d+)") RA:ScanBags() if arg then local id = tonumber(arg) print("Item " .. id .. " owned: " .. tostring(RelationshipsAttuneCharDB.items[id] == true)) end print("Keyring dump:") local probeBags = { -2, -1 } if KEYRING_CONTAINER and KEYRING_CONTAINER ~= -2 and KEYRING_CONTAINER ~= -1 then table.insert(probeBags, KEYRING_CONTAINER) end local tip = ensureTip() for _, bag in ipairs(probeBags) do local ok, slots = pcall(GetContainerNumSlots, bag) if not ok or not slots then slots = 0 end local probe = slots > 0 and slots or 32 print(" bag " .. bag .. " GetContainerNumSlots=" .. tostring(slots) .. " probing=" .. probe) for s = 1, probe do pcall(function() tip:ClearLines() end) pcall(function() tip:SetBagItem(bag, s) end) local ok2, link = pcall(GetContainerItemLink, bag, s) local name = tipItemName() if (ok2 and link) or name then print(" [" .. s .. "] link=" .. tostring(link) .. " name=" .. tostring(name)) end end end pcall(function() tip:Hide() end) print("Scarlet Key (7146) owned: " .. tostring(RelationshipsAttuneCharDB.items[7146] == true)) elseif msg == "help" or msg == "?" then print("Relationships Attune - commands:") print(" /attune - toggle the window") print(" /attune show - open the window") print(" /attune hide - close the window (or hide the minimap button)") print(" /attune minimap - toggle the minimap button") print(" /attune scan - force a rescan of quests + bags") print(" /attune sync - request sync from your group") print(" /attune debug - dump keyring + item detection") print(" /attune find - check if a specific item is detected") print(" /attune reset - wipe this character's data and rescan") else if RA.UI then RA.UI:Toggle() end end end SLASH_RELATIONSHIPSATTUNE1 = "/rattune" SLASH_RELATIONSHIPSATTUNE2 = "/relationshipsattune" SLASH_RELATIONSHIPSATTUNE3 = "/attune" SlashCmdList["RELATIONSHIPSATTUNE"] = slashHandler -- --------------------------------------------------------------- -- Event frame -- --------------------------------------------------------------- local ef = CreateFrame("Frame", "RelationshipsAttuneEventFrame") ef:RegisterEvent("PLAYER_LOGIN") ef:RegisterEvent("PLAYER_ENTERING_WORLD") ef:RegisterEvent("QUEST_LOG_UPDATE") ef:RegisterEvent("BAG_UPDATE") ef:RegisterEvent("PLAYER_LEVEL_UP") ef:RegisterEvent("UPDATE_FACTION") ef:RegisterEvent("CHAT_MSG_ADDON") pcall(function() ef:RegisterEvent("QUEST_TURNED_IN") end) pcall(function() ef:RegisterEvent("KEYRING_UPDATE") end) ef:SetScript("OnEvent", function() local e = event if e == "PLAYER_LOGIN" then RA:InitDB() RA.playerName = UnitName("player") RA.playerRealm = GetRealmName() or "Unknown" RA.playerKey = RA.playerName .. "-" .. RA.playerRealm RA.playerFaction = UnitFactionGroup("player") or "Neutral" RA:BuildNameIndex() RA:WarmItemCache() RA:ScanQuestLog(); RA:ScanBags(); RA:RecomputeAll() -- Retry pass: on 1.12 GetItemInfo populates async, and the -- keyring is not always populated at PLAYER_LOGIN. Schedule two -- delayed rescans so keys still get credited without user action. RA._retryAt = { GetTime() + 3, GetTime() + 10 } if RA.Comm and RA.Comm.Init then RA.Comm:Init() end if RA.UI and RA.UI.Init then RA.UI:Init() end if RA.Minimap and RA.Minimap.Init then RA.Minimap:Init() end RA.initialized = true print(L.ADDON_NAME .. " v" .. RA.VERSION .. " " .. L.MSG_LOADED .. " (" .. tostring(RA.playerFaction) .. "). Type /attune for options.") elseif e == "PLAYER_ENTERING_WORLD" then if RA.initialized then RA.playerFaction = UnitFactionGroup("player") or RA.playerFaction or "Neutral" RA:MarkDirty() end elseif e == "QUEST_LOG_UPDATE" or e == "BAG_UPDATE" or e == "KEYRING_UPDATE" or e == "PLAYER_LEVEL_UP" or e == "UPDATE_FACTION" then if RA.initialized then RA:MarkDirty() end elseif e == "QUEST_TURNED_IN" then if arg1 then RA:MarkQuestTurnedIn(arg1) end elseif e == "CHAT_MSG_ADDON" then if arg1 == RA.COMM_PREFIX and RA.Comm and RA.Comm.OnMessage then RA.Comm:OnMessage(arg2, arg3, arg4) end end end) ef:SetScript("OnUpdate", function() if not RA.initialized then return end RA:_OnUpdate(arg1) end)