From 510a991daf8273bed3763a72fcb37477ce2ffff1 Mon Sep 17 00:00:00 2001 From: Relationship <139162359+Relationship-OctoWoW@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:30:26 +0100 Subject: [PATCH] Add files via upload --- Comm.lua | 130 ++++++++ Core.lua | 650 ++++++++++++++++++++++++++++++++++++ Data.lua | 628 +++++++++++++++++++++++++++++++++++ Locales.lua | 33 ++ Minimap.lua | 147 +++++++++ README.md | 91 +++++ RelationshipsAttune.toc | 15 + UI.lua | 711 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 2405 insertions(+) create mode 100644 Comm.lua create mode 100644 Core.lua create mode 100644 Data.lua create mode 100644 Locales.lua create mode 100644 Minimap.lua create mode 100644 README.md create mode 100644 RelationshipsAttune.toc create mode 100644 UI.lua diff --git a/Comm.lua b/Comm.lua new file mode 100644 index 0000000..add314b --- /dev/null +++ b/Comm.lua @@ -0,0 +1,130 @@ +-- RelationshipsAttune: Comm.lua +-- Party/Raid/Guild addon-channel sync. Small, fixed-format messages so +-- we don't need a serializer library. + +RelationshipsAttune = RelationshipsAttune or {} +local RA = RelationshipsAttune +RA.Comm = {} +local C = RA.Comm + +-- Message formats (all pipe-delimited, single line): +-- V| -> version ping +-- R -> request full sync +-- S|||| -> per-attunement status +-- D||... -> per-step done bits +-- +-- Bits are '1'/'0' chars so we stay printable. + +local function send(msg, channel, target) + if not msg then return end + channel = channel or "PARTY" + if channel == "WHISPER" then + SendAddonMessage(RA.COMM_PREFIX, msg, channel, target) + else + SendAddonMessage(RA.COMM_PREFIX, msg, channel) + end +end + +local function currentChannel() + if GetNumRaidMembers and GetNumRaidMembers() > 0 then return "RAID" end + if GetNumPartyMembers and GetNumPartyMembers() > 0 then return "PARTY" end + return nil +end + +function C:Init() + -- On login, broadcast a version ping and share current state. + local ch = currentChannel() + if ch then + send("V|" .. RA.VERSION, ch) + self:BroadcastAll(ch) + end +end + +function C:RequestSync() + local ch = currentChannel() + if not ch then RA.print("No group to sync with."); return end + send("R", ch) + self:BroadcastAll(ch) +end + +function C:BroadcastAll(channel, target) + channel = channel or currentChannel() + if not channel then return end + local data = RelationshipsAttune_Data + for i = 1, table.getn(data) do + local att = data[i] + local prog = RelationshipsAttuneCharDB.progress[att.id] + if prog and prog.applicable then + send(string.format("S|%s|%d|%d|%d", + att.id, prog.doneCount, prog.total, prog.done and 1 or 0), + channel, target) + -- pack steps + local bits = "" + for s = 1, prog.total do + bits = bits .. (prog.steps[s] and "1" or "0") + end + send("D|" .. att.id .. "|" .. bits, channel, target) + end + end +end + +local function ensurePlayer(sender) + RelationshipsAttuneDB.players = RelationshipsAttuneDB.players or {} + local key = sender -- vanilla addon sender = "Name" (no realm) + if not RelationshipsAttuneDB.players[key] then + RelationshipsAttuneDB.players[key] = { + name = sender, realm = RA.playerRealm, faction = "Unknown", + progress = {}, updated = time and time() or 0, + } + end + return RelationshipsAttuneDB.players[key] +end + +function C:OnMessage(msg, channel, sender) + if not msg or not sender then return end + if sender == RA.playerName then return end -- ignore self-echo + + local _, _, tag, rest = string.find(msg, "^(%a)|?(.*)$") + if not tag then return end + local p = ensurePlayer(sender) + p.updated = time and time() or 0 + + if tag == "V" then + -- version ping; nothing to do beyond note + p.version = rest + + elseif tag == "R" then + -- someone wants my data + self:BroadcastAll(channel, sender) + + elseif tag == "S" then + -- S|attId|done|total|doneBool + local _, _, attId, d, t, full = string.find(rest, "^([^|]+)|(%d+)|(%d+)|(%d+)$") + if attId then + p.progress = p.progress or {} + local prog = p.progress[attId] or { steps = {} } + prog.applicable = true + prog.doneCount = tonumber(d) or 0 + prog.total = tonumber(t) or 0 + prog.done = (tonumber(full) == 1) + p.progress[attId] = prog + end + + elseif tag == "D" then + -- D|attId|bits + local _, _, attId, bits = string.find(rest, "^([^|]+)|([01]*)$") + if attId and bits then + p.progress = p.progress or {} + local prog = p.progress[attId] or { steps = {}, done = false, doneCount = 0, total = string.len(bits) } + local steps = {} + for i = 1, string.len(bits) do + steps[i] = (string.sub(bits, i, i) == "1") + end + prog.steps = steps + prog.total = prog.total or string.len(bits) + p.progress[attId] = prog + end + end + + if RA.UI and RA.UI.Refresh then RA.UI:Refresh() end +end diff --git a/Core.lua b/Core.lua new file mode 100644 index 0000000..82cf760 --- /dev/null +++ b/Core.lua @@ -0,0 +1,650 @@ +-- 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) diff --git a/Data.lua b/Data.lua new file mode 100644 index 0000000..166f482 --- /dev/null +++ b/Data.lua @@ -0,0 +1,628 @@ +-- RelationshipsAttune: Data.lua +-- Attunement definitions with icons + hover descriptions for the UI. +-- +-- Sources: wowhead classic attunement guides (Onyxia, MC, BWL, Naxx, AQ40). +-- Turtle/Octo custom raids keep placeholder ids until server confirms them. +-- +-- Each step: +-- { type = "level", level = 60, name = "...", icon = "..." } +-- { type = "quest", id = , name = "...", icon = "...", +-- faction = "Alliance"|"Horde"|nil, desc = "...", start = "..." } +-- { type = "item", id = , name = "...", icon = "...", desc = "...", start = "..." } +-- { type = "rep", faction = "Argent Dawn", standing = 5, name = "...", +-- desc = "...", start = "..." } +-- +-- Standing: 1=Hostile ... 4=Neutral, 5=Honored, 6=Revered, 7=Exalted. + +local ICON_QUEST = "Interface\\GossipFrame\\AvailableQuestIcon" +local ICON_ACTIVE = "Interface\\GossipFrame\\ActiveQuestIcon" +local ICON_KEY = "Interface\\Icons\\INV_Misc_Key_03" +local ICON_LEVEL = "Interface\\Icons\\Spell_Nature_EnchantArmor" +local ICON_REP = "Interface\\Icons\\Achievement_Reputation_01" +local ICON_ITEM = "Interface\\Icons\\INV_Misc_QuestionMark" + +RelationshipsAttune_Data = { + + ---------------------------------------------------------------- + -- ONYXIA'S LAIR - full Drakefire Amulet chain (both factions) + ---------------------------------------------------------------- + { + id = "ony", name = "Onyxia's Lair", category = "Vanilla", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Head_Dragon_01", + steps = { + { type = "level", level = 55, name = "Reach Level 55", icon = ICON_LEVEL, + desc = "Minimum level to start either faction's Onyxia attunement chain." }, + + ------------------------------------------------------------------ + -- ALLIANCE CHAIN (starts Burning Steppes -> Redridge -> Stormwind + -- -> BRD -> back to Stormwind -> Winterspring -> UBRS). + ------------------------------------------------------------------ + { type = "quest", id = 4182, faction = "Alliance", + name = "Dragonkin Menace", icon = ICON_QUEST, + desc = "Slay black dragonkin around Burning Steppes to open the chain.", + start = "Helendis Riverhorn, Morgan's Vigil, Burning Steppes (86, 69)." }, + { type = "quest", id = 4183, faction = "Alliance", + name = "The True Masters (Helendis)", icon = ICON_QUEST, + desc = "Deliver Helendis Riverhorn's Letter to Magistrate Solomon in Lakeshire.", + start = "Helendis Riverhorn, Morgan's Vigil, Burning Steppes (86, 69)." }, + { type = "quest", id = 4184, faction = "Alliance", + name = "The True Masters (Solomon -> Bolvar)", icon = ICON_QUEST, + desc = "Take Solomon's Plea to Highlord Bolvar Fordragon in Stormwind Keep.", + start = "Magistrate Solomon, Lakeshire town hall, Redridge Mountains (30, 44)." }, + { type = "quest", id = 4185, faction = "Alliance", + name = "The True Masters (Prestor)", icon = ICON_QUEST, + desc = "Speak with Lady Katrana Prestor, then return to Bolvar.", + start = "Highlord Bolvar Fordragon, Stormwind Keep throne room (78, 18)." }, + { type = "quest", id = 4186, faction = "Alliance", + name = "The True Masters (Bolvar's Decree)", icon = ICON_QUEST, + desc = "Return Bolvar's Decree to Magistrate Solomon in Lakeshire.", + start = "Highlord Bolvar Fordragon, Stormwind Keep (78, 18)." }, + { type = "quest", id = 4223, faction = "Alliance", + name = "The True Masters (Maxwell)", icon = ICON_QUEST, + desc = "Report to Marshal Maxwell at Morgan's Vigil.", + start = "Magistrate Solomon, Lakeshire, Redridge Mountains (30, 44)." }, + { type = "quest", id = 4224, faction = "Alliance", + name = "The True Masters (Ragged John)", icon = ICON_QUEST, + desc = "Find Ragged John in a cave north of Thaurissan, then return to Maxwell.", + start = "Marshal Maxwell, Morgan's Vigil, Burning Steppes (85, 69). Ragged John: cave at (65, 24)." }, + { type = "quest", id = 4241, faction = "Alliance", + name = "Marshal Windsor", icon = ICON_QUEST, + desc = "Enter Blackrock Depths and find Marshal Windsor in his cell.", + start = "Marshal Maxwell, Morgan's Vigil, Burning Steppes (85, 69). BRD: Blackrock Mountain." }, + { type = "quest", id = 4242, faction = "Alliance", + name = "Abandoned Hope", icon = ICON_QUEST, + desc = "Return the bad news from Windsor to Marshal Maxwell.", + start = "Marshal Windsor, Windsor Cell, Blackrock Depths jail." }, + { type = "quest", id = 4264, faction = "Alliance", + name = "A Crumpled Up Note", icon = ICON_QUEST, + desc = "Random drop item from BRD slaves/mobs; return it to Windsor.", + start = "Loot A Crumpled Up Note from BRD slaves (outside instance) or trash inside." }, + { type = "quest", id = 4282, faction = "Alliance", + name = "A Shred of Hope", icon = ICON_QUEST, + desc = "Kill General Angerforge and Golem Lord Argelmach in BRD and loot Windsor's Lost Information from each.", + start = "Marshal Windsor, Windsor Cell, Blackrock Depths jail." }, + { type = "quest", id = 4322, faction = "Alliance", + name = "Jail Break!", icon = ICON_QUEST, + desc = "Escort Marshal Windsor out of BRD, then report to Marshal Maxwell.", + start = "Marshal Windsor, Windsor Cell, Blackrock Depths jail." }, + { type = "quest", id = 6402, faction = "Alliance", + name = "Stormwind Rendezvous", icon = ICON_QUEST, + desc = "Speak to Squire Rowe at the Stormwind gates to summon Reginald Windsor.", + start = "Marshal Maxwell, Morgan's Vigil, Burning Steppes (85, 69). Squire Rowe: Stormwind gates (70, 86)." }, + { type = "quest", id = 6403, faction = "Alliance", + name = "The Great Masquerade", icon = ICON_ACTIVE, + desc = "Escort Reginald Windsor to Stormwind Keep; unmask Lady Prestor as Onyxia.", + start = "Reginald Windsor, Stormwind City gates (after Rendezvous)." }, + { type = "quest", id = 6501, faction = "Alliance", + name = "The Dragon's Eye", icon = ICON_QUEST, + desc = "Take the Fragment of the Dragon's Eye to Haleh in Mazthoril, Winterspring.", + start = "Highlord Bolvar Fordragon, Stormwind Keep (78, 18). Haleh: Mazthoril cave, Winterspring (55, 51)." }, + { type = "quest", id = 6502, faction = "Alliance", + name = "Drakefire Amulet (Alliance)", icon = ICON_ACTIVE, + desc = "Kill General Drakkisath in Upper Blackrock Spire and loot Blood of the Black Dragon Champion; return to Haleh.", + start = "Haleh, Mazthoril cave, Winterspring (55, 51)." }, + + ------------------------------------------------------------------ + -- HORDE CHAIN (Kargath -> LBRS -> Orgrimmar -> UBRS -> Desolace + -- -> Western Plaguelands -> Dustwallow -> Tanaris/Winterspring/ + -- Swamp of Sorrows/Wetlands -> Desolace -> UBRS). + ------------------------------------------------------------------ + { type = "quest", id = 4903, faction = "Horde", + name = "Warlord's Command", icon = ICON_QUEST, + desc = "Kill Highlord Omokk, War Master Voone and Overlord Wyrmthalak in LBRS; loot Important Blackrock Documents.", + start = "Warlord Goretooth, Kargath command tent, Badlands (6, 48)." }, + { type = "quest", id = 4941, faction = "Horde", + name = "Eitrigg's Wisdom", icon = ICON_QUEST, + desc = "Speak to Eitrigg in Orgrimmar, then turn in to Thrall.", + start = "Warlord Goretooth, Kargath, Badlands (6, 48). Eitrigg/Thrall: Valley of Wisdom, Orgrimmar (32, 38)." }, + { type = "quest", id = 4974, faction = "Horde", + name = "For The Horde!", icon = ICON_QUEST, + desc = "Kill Warchief Rend Blackhand in UBRS and return his head to Thrall (grants Warchief's Blessing).", + start = "Thrall, Grommash Hold, Valley of Wisdom, Orgrimmar (32, 38)." }, + { type = "quest", id = 6566, faction = "Horde", + name = "What the Wind Carries", icon = ICON_QUEST, + desc = "Listen to Thrall's tale and turn in.", + start = "Thrall, Grommash Hold, Valley of Wisdom, Orgrimmar (32, 38)." }, + { type = "quest", id = 6567, faction = "Horde", + name = "The Champion of the Horde", icon = ICON_QUEST, + desc = "Find Rexxar patrolling between Desolace, Stonetalon and Feralas.", + start = "Thrall, Grommash Hold, Orgrimmar (32, 38). Rexxar: Desolace patrol path." }, + { type = "quest", id = 6568, faction = "Horde", + name = "The Testament of Rexxar", icon = ICON_QUEST, + desc = "Deliver Rexxar's Testament to Myranda the Hag in Western Plaguelands.", + start = "Rexxar, Desolace (roaming). Myranda: near Uther's Tomb, Western Plaguelands (51, 78)." }, + { type = "quest", id = 6569, faction = "Horde", + name = "Oculus Illusions", icon = ICON_QUEST, + desc = "Collect 20 Black Dragonspawn Eyes from UBRS dragonkin for your disguise.", + start = "Myranda the Hag, Western Plaguelands (51, 78)." }, + { type = "quest", id = 6570, faction = "Horde", + name = "Emberstrife", icon = ICON_QUEST, + desc = "Use Amulet of Draconic Subversion and speak to Emberstrife in his den, Dustwallow Marsh.", + start = "Myranda the Hag, Western Plaguelands (51, 78). Emberstrife's Den: Dustwallow Marsh (57, 87)." }, + { type = "quest", id = 6584, faction = "Horde", + name = "The Test of Skulls, Chronalis", icon = ICON_QUEST, + desc = "Kill Chronalis (Bronze) outside Caverns of Time, Tanaris.", + start = "Emberstrife, Emberstrife's Den, Dustwallow Marsh (57, 87). Chronalis: Tanaris (65, 50)." }, + { type = "quest", id = 6582, faction = "Horde", + name = "The Test of Skulls, Scryer", icon = ICON_QUEST, + desc = "Kill Scryer (Blue) inside Mazthoril cave, Winterspring.", + start = "Emberstrife, Emberstrife's Den, Dustwallow Marsh (57, 87). Scryer: Mazthoril (53, 56)." }, + { type = "quest", id = 6583, faction = "Horde", + name = "The Test of Skulls, Somnus", icon = ICON_QUEST, + desc = "Kill Somnus (Green) patrolling south-east Swamp of Sorrows.", + start = "Emberstrife, Emberstrife's Den, Dustwallow Marsh (57, 87). Somnus: Swamp of Sorrows (~80, 70)." }, + { type = "quest", id = 6585, faction = "Horde", + name = "The Test of Skulls, Axtroz", icon = ICON_QUEST, + desc = "Unlocks after the other three Skull quests. Kill Axtroz (Red) at Grim Batol, Wetlands.", + start = "Emberstrife, Emberstrife's Den, Dustwallow Marsh (57, 87). Axtroz: Wetlands (85, 49)." }, + { type = "quest", id = 6601, faction = "Horde", + name = "Ascension...", icon = ICON_QUEST, + desc = "Take the Dull Drakefire Amulet to Rexxar in Desolace (not to Drakkisath).", + start = "Emberstrife, Emberstrife's Den, Dustwallow Marsh (57, 87). Rexxar: Desolace patrol." }, + { type = "quest", id = 6602, faction = "Horde", + name = "Blood of the Black Dragon Champion", icon = ICON_ACTIVE, + desc = "Kill General Drakkisath in UBRS, loot his blood and return to Rexxar for the Drakefire Amulet.", + start = "Rexxar, Desolace patrol path." }, + + ------------------------------------------------------------------ + -- Final reward (both factions). + ------------------------------------------------------------------ + { type = "item", id = 16309, name = "Drakefire Amulet", + icon = "Interface\\Icons\\INV_Jewelry_Talisman_11", + desc = "Must be in every raid member's bags (not bank) to enter Onyxia's Lair.", + start = "Awarded by Haleh (Alliance, Winterspring) or Rexxar (Horde, Desolace patrol)." }, + }, + }, + + ---------------------------------------------------------------- + -- MOLTEN CORE + ---------------------------------------------------------------- + { + id = "mc", name = "Molten Core", category = "Vanilla", faction = "Both", + icon = "Interface\\Icons\\Spell_Fire_SelfDestruct", + steps = { + { type = "level", level = 55, name = "Reach Level 55", icon = ICON_LEVEL }, + { type = "item", id = 17203, name = "Sulfuron Ingot (optional binder)", + icon = "Interface\\Icons\\INV_Ingot_08", + desc = "Not required for attunement itself, but drops in MC; listed for group planning.", + start = "Trash drops throughout Molten Core." }, + { type = "quest", id = 7487, name = "The Molten Core (BRD group summon)", icon = ICON_QUEST, + desc = "Group prereq that starts the Attunement chain via Lothos Riftwaker.", + start = "Lothos Riftwaker, Blackrock Mountain (top of chained platform near the MC portal)." }, + { type = "quest", id = 7848, name = "Attunement to the Core", icon = ICON_ACTIVE, + desc = "Turn in a Core Fragment (from Molten Core mobs) to Lothos Riftwaker; grants direct teleport into MC from BRD.", + start = "Lothos Riftwaker, Blackrock Mountain (BRD entrance platform)." }, + }, + }, + + ---------------------------------------------------------------- + -- BLACKWING LAIR + ---------------------------------------------------------------- + { + id = "bwl", name = "Blackwing Lair", category = "Vanilla", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Head_Dragon_Black", + steps = { + { type = "level", level = 58, name = "Reach Level 58", icon = ICON_LEVEL }, + { type = "item", id = 12344, name = "Seal of Ascension (UBRS key prereq)", icon = ICON_KEY, + desc = "You must be able to enter UBRS to reach Vaelan; requires the Seal of Ascension chain.", + start = "Chain starts with Kibler at Flame Crest, Burning Steppes (62, 24), and Jorgen at Morgan's Vigil (85, 68)." }, + { type = "quest", id = 7761, name = "Blackhand's Command", icon = ICON_ACTIVE, + desc = "Read the scroll dropped by Vaelan behind the hidden door in UBRS, then use the orb at the top of UBRS to enter BWL.", + start = "Vaelan, hidden alcove past the UBRS orb room, Upper Blackrock Spire." }, + }, + }, + + ---------------------------------------------------------------- + -- NAXXRAMAS + ---------------------------------------------------------------- + { + id = "naxx", name = "Naxxramas", category = "Vanilla", faction = "Both", + icon = "Interface\\Icons\\Spell_Shadow_DeathPact", + steps = { + { type = "level", level = 60, name = "Reach Level 60", icon = ICON_LEVEL }, + { type = "rep", faction = "Argent Dawn", standing = 5, + name = "Honored with the Argent Dawn", icon = ICON_REP, + desc = "Required to purchase the Naxxramas attunement from Archmage Angela Dosantos. Higher rep reduces the gold cost (60g Honored, 40g Revered, 20g Exalted).", + start = "Turn in Scourgestones / Minion's Scourgestones to Argent Dawn quartermasters at Light's Hope Chapel, Eastern Plaguelands (76, 53)." }, + { type = "quest", id = 9121, name = "The Dread Citadel - Naxxramas! (Honored, 60g)", icon = ICON_ACTIVE, + desc = "Pay 60g to Angela Dosantos - available at Honored with Argent Dawn.", + start = "Archmage Angela Dosantos, Light's Hope Chapel, Eastern Plaguelands (76, 53)." }, + { type = "quest", id = 9122, name = "The Dread Citadel - Naxxramas! (Revered, 40g)", icon = ICON_ACTIVE, + desc = "Discounted Naxx attunement offered at Revered with Argent Dawn.", + start = "Archmage Angela Dosantos, Light's Hope Chapel, Eastern Plaguelands (76, 53)." }, + { type = "quest", id = 9123, name = "The Dread Citadel - Naxxramas! (Exalted, 20g)", icon = ICON_ACTIVE, + desc = "Cheapest Naxx attunement, offered only at Exalted with Argent Dawn.", + start = "Archmage Angela Dosantos, Light's Hope Chapel, Eastern Plaguelands (76, 53)." }, + }, + }, + + ---------------------------------------------------------------- + -- AQ20 - Ruins of Ahn'Qiraj + ---------------------------------------------------------------- + { + id = "aq20", name = "Ruins of Ahn'Qiraj (20)", category = "Vanilla", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_QirajiCrystal_02", + steps = { + { type = "level", level = 60, name = "Reach Level 60", icon = ICON_LEVEL }, + { type = "rep", faction = "Cenarion Circle", standing = 4, + name = "Neutral with Cenarion Circle", icon = ICON_REP, + desc = "Automatic from Silithus mobs after the war effort opens; no separate attunement item.", + start = "Windcaller Yessendra / Proudhorn, Cenarion Hold, Silithus (50, 33)." }, + { type = "quest", id = 8743, name = "The Gates of Ahn'Qiraj (server-wide event)", icon = ICON_QUEST, + desc = "The AQ gates must have been opened server-side. Once open, anyone can walk in.", + start = "Anachronos, Caverns of Time entrance, Tanaris (65, 50)." }, + }, + }, + + ---------------------------------------------------------------- + -- AQ40 - Temple of Ahn'Qiraj (Scepter of the Shifting Sands) + -- Chain sourced from CixiDelmont's Attune addon (AttuneData id=10). + ---------------------------------------------------------------- + { + id = "aq40", name = "Temple of Ahn'Qiraj (40)", category = "Vanilla", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_QirajiCrystal_05", + steps = { + { type = "level", level = 60, name = "Reach Level 60", icon = ICON_LEVEL }, + { type = "quest", id = 8286, name = "What Tomorrow Brings", icon = ICON_QUEST, + desc = "Speak with Anachronos at the Caverns of Time to begin the Scepter chain.", + start = "Anachronos, Caverns of Time entrance, Tanaris (65, 50). Talk to him at the Gates of Ahn'Qiraj first (Silithus)." }, + { type = "quest", id = 8288, name = "Only One May Rise", icon = ICON_QUEST, + desc = "Kill Vaelastrasz in Blackwing Lair and loot the Vessel of Rebirth.", + start = "Anachronos, Silithus / Caverns of Time." }, + { type = "quest", id = 8301, name = "The Path of the Righteous", icon = ICON_QUEST, + desc = "Continue Anachronos' story after handing in the Vessel.", + start = "Anachronos, Caverns of Time entrance, Tanaris (65, 50)." }, + { type = "rep", faction = "Brood of Nozdormu", standing = 4, + name = "Neutral with Brood of Nozdormu", icon = ICON_REP, + desc = "Kill AQ40 trash / bosses to raise Nozdormu rep from Hated to Neutral; required before Anachronos will continue.", + start = "Kill Qiraji inside AQ40; automatic once AQ40 is open." }, + { type = "quest", id = 8303, name = "Chamber of the Aspects", icon = ICON_QUEST, + desc = "Anachronos sends you to speak with the four dragon Aspect representatives.", + start = "Anachronos, Caverns of Time entrance, Tanaris (65, 50)." }, + { type = "quest", id = 8305, name = "Anachronos", icon = ICON_QUEST, + desc = "Return to Anachronos with the Aspects' answers.", + start = "Anachronos, Caverns of Time entrance, Tanaris (65, 50)." }, + { type = "quest", id = 8519, name = "The Might of Kalimdor", icon = ICON_QUEST, + desc = "Anachronos gives you the Broken Scepter of the Shifting Sands and directs you to the Aspects.", + start = "Anachronos, Caverns of Time entrance, Tanaris (65, 50)." }, + { type = "quest", id = 8555, name = "The Nightmare's Corruption (Red)", icon = ICON_QUEST, + desc = "Kill Vaelastrasz (BWL), loot Vaelastrasz's Tooth for Caelestrasz.", + start = "Caelestrasz, Anachronos' platform, Caverns of Time." }, + { type = "quest", id = 8733, name = "Beyond the Grave (Green)", icon = ICON_QUEST, + desc = "Investigate the pool inside The Temple of Atal'Hakkar for Merithra.", + start = "Merithra of the Dream, Caverns of Time." }, + { type = "quest", id = 8734, name = "Draconic for Dummies (Blue)", icon = ICON_QUEST, + desc = "Arygos sends you to research Nightmare Scale fragments across Azeroth.", + start = "Arygos, Caverns of Time." }, + { type = "quest", id = 8735, name = "Stones of Binding (Bronze)", icon = ICON_QUEST, + desc = "Retrieve five Nightmare Engravings from world dragons (Ysondre, Emeriss, Lethon, Taerar and Azuregos) for Kalecgos.", + start = "Kalecgos, Caverns of Time." }, + { type = "quest", id = 8736, name = "Preparations", icon = ICON_QUEST, + desc = "Return to Anachronos with the Nightmare Scale.", + start = "Anachronos, Caverns of Time." }, + { type = "quest", id = 8741, name = "The Charge of the Dragonflights", icon = ICON_QUEST, + desc = "The Aspects reforge the scepter. Deliver the Fragment of the Nightmare's Corruption.", + start = "Anachronos, Caverns of Time." }, + { type = "quest", id = 8742, name = "The Hand of the Righteous", icon = ICON_QUEST, + desc = "Final scepter reforging quest. Rewards Scepter of the Shifting Sands.", + start = "Anachronos, Caverns of Time." }, + { type = "item", id = 21175, name = "Scepter of the Shifting Sands", + icon = "Interface\\Icons\\INV_Hammer_25", + desc = "Use this item at the Scarab Gong outside AQ to trigger the 10-hour war and open the gates server-wide.", + start = "Reward from 'The Hand of the Righteous'." }, + { type = "quest", id = 8743, name = "Bang a Gong!", icon = ICON_ACTIVE, + desc = "Ring the Scarab Gong at the Gates of Ahn'Qiraj to open both AQ raids server-wide. First person to ring gets Scarab Lord title + mount.", + start = "Scarab Gong, Gates of Ahn'Qiraj, Silithus." }, + { type = "item", id = 21176, name = "Black Qiraji Resonating Crystal (Scarab Lord mount)", + icon = "Interface\\Icons\\INV_Misc_QirajiCrystal_04", + desc = "Awarded only to the player who rings the gong within the 10-hour war window.", + start = "Reward for completing 'Bang a Gong!' during the war event." }, + { type = "quest", id = 8745, name = "Ouro's Message (post-war attunement)", icon = ICON_QUEST, + desc = "Anyone can walk into AQ40 once the gates are open; no personal attunement item required after the event.", + start = "Anachronos - concludes the Scepter chain." }, + }, + }, + + ---------------------------------------------------------------- + -- VANILLA KEYS & DUNGEON ACCESS + ---------------------------------------------------------------- + { + id = "ubrs", name = "Upper Blackrock Spire", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Key_04", + steps = { + { type = "level", level = 55, name = "Reach Level 55", icon = ICON_LEVEL }, + { type = "quest", id = 4903, name = "Kibler's Exotic Pets (chain start)", icon = ICON_QUEST, + desc = "Opens Jorgen's Seal of Ascension chain.", + start = "Kibler, Flame Crest, Burning Steppes (62, 24)." }, + { type = "quest", id = 4743, name = "Seal of Ascension", icon = ICON_QUEST, + desc = "Requires the Unadorned Seal of Ascension (drops in LBRS) plus three gemstones from LBRS bosses.", + start = "Jorgen, Morgan's Vigil, Burning Steppes (85, 68)." }, + { type = "item", id = 12344, name = "Seal of Ascension", icon = ICON_KEY, + desc = "Opens the door to Upper Blackrock Spire at the top of the LBRS instance.", + start = "Awarded by Jorgen at Morgan's Vigil after completing the Seal of Ascension chain." }, + }, + }, + + { + id = "brd", name = "Blackrock Depths", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Key_06", + steps = { + { type = "level", level = 47, name = "Reach Level 47", icon = ICON_LEVEL }, + { type = "quest", id = 4136, name = "Dark Iron Legacy", icon = ICON_QUEST, + desc = "Rewards the Shadowforge Key for opening the deeper doors in BRD.", + start = "Franclorn Forgewright's ghost, inside Blackrock Depths (Detention Block hallway near his statue)." }, + { type = "item", id = 11000, name = "Shadowforge Key", icon = ICON_KEY, + desc = "Opens Shadowforge City / Manufactory doors deep in BRD.", + start = "Reward from 'Dark Iron Legacy'." }, + }, + }, + + { + id = "scholo", name = "Scholomance", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Key_11", + steps = { + { type = "level", level = 55, name = "Reach Level 55", icon = ICON_LEVEL }, + { type = "quest", id = 5382, name = "The Key to Scholomance", icon = ICON_QUEST, + desc = "Requires Blood of Innocents (Scarlet Monastery), Viewing Room Key (Scholomance) and Deed to Caer Darrow.", + start = "Alliance: Alexi Barov, Hearthglen, Western Plaguelands (44, 21). Horde: Alexi Barov, Ruins of Andorhal (42, 68)." }, + { type = "item", id = 13704, name = "Skeleton Key", icon = ICON_KEY, + desc = "Opens the front door of Scholomance in Caer Darrow.", + start = "Reward from 'The Key to Scholomance'." }, + }, + }, + + { + id = "strat_live", name = "Stratholme (Service Entrance)", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Key_07", + steps = { + { type = "level", level = 55, name = "Reach Level 55", icon = ICON_LEVEL }, + { type = "item", id = 12382, name = "Key to the City", icon = ICON_KEY, + desc = "Opens the Service Entrance shortcut into Stratholme Live.", + start = "Drops from Magistrate Barthilas, patrolling the main road of Stratholme Live." }, + }, + }, + + { + id = "dm", name = "Dire Maul", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Key_09", + steps = { + { type = "level", level = 55, name = "Reach Level 55", icon = ICON_LEVEL }, + { type = "item", id = 18249, name = "Crescent Key", icon = ICON_KEY, + desc = "Opens locked doors and cages throughout Dire Maul (all three wings).", + start = "Pickpocket from Kobold Geomancers inside Dire Maul West (Rogue only), lockpick at 300, or buy from the AH." }, + }, + }, + + { + id = "gnomer", name = "Gnomeregan (Workshop Shortcut)", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Key_08", + steps = { + { type = "level", level = 24, name = "Reach Level 24", icon = ICON_LEVEL }, + { type = "quest", id = 2904, name = "Grime-Encrusted Ring", icon = ICON_QUEST, + desc = "Drop-quest item off Gnomeregan mobs; leads into 'Return to Marvon'.", + start = "Loot Grime-Encrusted Ring inside Gnomeregan (Dun Morogh 25, 40)." }, + { type = "quest", id = 2905, name = "Return to Marvon", icon = ICON_QUEST, + desc = "Rewards the Workshop Key for skipping Grubbis.", + start = "Marvon Rivetseeker, Steamwheedle Port camp, Tanaris (66, 22)." }, + { type = "item", id = 9240, name = "Workshop Key", icon = ICON_KEY, + desc = "Opens the shortcut door in Gnomeregan bypassing the Grubbis wing.", + start = "Reward from 'Return to Marvon'." }, + }, + }, + + { + id = "mara", name = "Maraudon (Portal Shortcut)", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Idol_02", + steps = { + { type = "level", level = 40, name = "Reach Level 40", icon = ICON_LEVEL }, + { type = "quest", id = 7064, name = "Legends of Maraudon", icon = ICON_QUEST, + desc = "Prereq for the Scepter of Celebras.", + start = "Celebras the Redeemed, freed on the Orange side of Maraudon after the vine tunnel." }, + { type = "quest", id = 7065, name = "The Scepter of Celebras", icon = ICON_QUEST, + desc = "Rewards Scepter of Celebras (teleport into inner Maraudon).", + start = "Celebras the Redeemed, Maraudon (Orange side)." }, + { type = "item", id = 17191, name = "Scepter of Celebras", icon = ICON_KEY, + desc = "Teleports directly to the Princess wing of Maraudon.", + start = "Reward from 'The Scepter of Celebras'." }, + }, + }, + + { + id = "st", name = "Sunken Temple (Jammal'an)", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Idol_03", + steps = { + { type = "level", level = 50, name = "Reach Level 50", icon = ICON_LEVEL }, + { type = "quest", id = 3373, name = "The Ancient Egg / Jammal'an the Prophet", icon = ICON_QUEST, + desc = "Activates the six-statue puzzle so Jammal'an can be summoned.", + start = "Yeh'kinya, Steamwheedle Port, Tanaris (66, 22). Sunken Temple: Swamp of Sorrows (69, 53)." }, + }, + }, + + { + id = "hydraxian", name = "Hydraxian Waterlords (MC Runes)", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\Spell_Frost_SummonWaterElemental", + steps = { + { type = "level", level = 60, name = "Reach Level 60", icon = ICON_LEVEL }, + { type = "rep", faction = "Hydraxian Waterlords", standing = 5, + name = "Honored with the Hydraxian Waterlords", icon = ICON_REP, + desc = "Needed to purchase Aqual Quintessence to douse the Molten Core runes for Majordomo.", + start = "Duke Hydraxis, small island off south-east Azshara (79, 76). Turn in water elemental drops (Azshara/Silithus) and MC boss items." }, + }, + }, + + { + id = "sm_cath", name = "Scarlet Monastery (Cathedral)", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Key_02", + steps = { + { type = "level", level = 35, name = "Reach Level 35", icon = ICON_LEVEL }, + { type = "quest", id = 1050, name = "Clear the Scarlet Monastery Library", icon = ICON_QUEST, + desc = "Clear the Library wing and loot the small chest behind the final boss to obtain the Scarlet Key.", + start = "Scarlet Monastery Library wing entrance, Tirisfal Glades (85, 32)." }, + { type = "item", id = 7146, name = "Scarlet Key", icon = ICON_KEY, + desc = "Opens the front doors to the Scarlet Monastery Cathedral wing.", + start = "Found in a small loot chest behind Arcanist Doan at the end of the Library wing (not a boss drop)." }, + }, + }, + + { + id = "brd_prison", name = "BRD Prison Cell Key", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Key_05", + steps = { + { type = "level", level = 52, name = "Reach Level 52", icon = ICON_LEVEL }, + { type = "item", id = 11266, name = "Prison Cell Key", icon = ICON_KEY, + desc = "Unlocks the Detention Block cells in Blackrock Depths (frees Marshal Windsor, Dughal, Kharan, etc).", + start = "Drops from Warder Stilgiss in the Detention Block, Blackrock Depths." }, + }, + }, + + { + id = "sgorge", name = "Searing Gorge Access", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Gem_Ruby_02", + steps = { + { type = "level", level = 45, name = "Reach Level 45", icon = ICON_LEVEL }, + { type = "quest", id = 3441, faction = "Alliance", + name = "A Bindings Blockade (Alliance)", icon = ICON_QUEST, + desc = "Opens the Thorium Brotherhood gate from Loch Modan into Searing Gorge.", + start = "Prospector Stormpike, The Military Ward, Ironforge." }, + { type = "quest", id = 3701, faction = "Horde", + name = "The Flame's Casing (Horde)", icon = ICON_QUEST, + desc = "Rewards Horde access through the Thorium Brotherhood gate into Searing Gorge.", + start = "Krakle, Kargath, Badlands (3, 45)." }, + }, + }, + + { + id = "uldaman", name = "Uldaman (Discs of Norgannon)", category = "Keys & Access", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Gem_Diamond_01", + steps = { + { type = "level", level = 40, name = "Reach Level 40", icon = ICON_LEVEL }, + { type = "quest", id = 2278, name = "The Lost Tablets of Will", icon = ICON_QUEST, + desc = "Prereq to activating the Discs of Norgannon so Archaedas can be reached.", + start = "Alliance: Advisor Belgrum, Ironforge. Horde: Rigglefuzz, Hillsbrad Foothills." }, + { type = "quest", id = 2418, name = "The Platinum Discs", icon = ICON_QUEST, + desc = "Interact with the Discs of Norgannon inside the Chamber of Khaz'mul to spawn Archaedas.", + start = "Uldaman, Badlands (42, 20)." }, + }, + }, + + ---------------------------------------------------------------- + -- TURTLE / OCTO WOW CUSTOM CONTENT + -- Placeholder ids; replace once your server confirms them. + ---------------------------------------------------------------- + { + id = "kara10", name = "Karazhan Crypt (10)", category = "Turtle", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Gem_Sapphire_02", + steps = { + { type = "level", level = 55, name = "Reach Level 55", icon = ICON_LEVEL }, + { type = "quest", id = 40001, name = "TODO: Crypt attunement quest", icon = ICON_QUEST, + desc = "Custom Turtle attunement chain for Karazhan Crypt (10-man).", + start = "Koren the Seeker, Morgan's Plot camp, Deadwind Pass (40, 75)." }, + { type = "item", id = 60001, name = "TODO: Crypt Key", icon = ICON_KEY, + desc = "Key required to enter the crypt beneath Karazhan.", + start = "Awarded from Koren the Seeker after completing the Karazhan Crypt attunement chain." }, + }, + }, + + { + id = "kara40", name = "Lower Karazhan Halls (40)", category = "Turtle", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Gem_Sapphire_01", + steps = { + { type = "level", level = 60, name = "Reach Level 60", icon = ICON_LEVEL }, + { type = "quest", id = 40010, name = "TODO: LKH attunement", icon = ICON_QUEST, + desc = "Custom Turtle attunement chain for Lower Karazhan Halls (40-man).", + start = "Archmage Alturus, Morgan's Plot camp, Deadwind Pass (40, 75)." }, + { type = "item", id = 60010, name = "TODO: LKH Key", icon = ICON_KEY, + desc = "40-man Karazhan key.", + start = "Awarded from Archmage Alturus at the end of the LKH attunement." }, + }, + }, + + { + id = "es", name = "Emerald Sanctum", category = "Turtle", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Head_Dragon_Green", + steps = { + { type = "level", level = 60, name = "Reach Level 60", icon = ICON_LEVEL }, + { type = "quest", id = 40020, name = "TODO: ES attunement", icon = ICON_QUEST, + desc = "Emerald Sanctum attunement chain (Hinterlands portal).", + start = "Keeper Remulos avatar, eastern Hinterlands near Seradane (55, 22)." }, + }, + }, + + { + id = "gilneas", name = "Gilneas City", category = "Turtle", faction = "Both", + icon = "Interface\\Icons\\Ability_Mount_WhiteDireWolf", + steps = { + { type = "level", level = 55, name = "Reach Level 55", icon = ICON_LEVEL }, + { type = "quest", id = 40030, name = "TODO: Gilneas attunement", icon = ICON_QUEST, + desc = "Attunement for the Gilneas City dungeon.", + start = "Lord Darius Crowley, Greymane Wall entrance, southern Silverpine Forest (43, 66)." }, + }, + }, + + { + id = "hateforge", name = "Hateforge Quarry", category = "Turtle", faction = "Both", + icon = "Interface\\Icons\\INV_Hammer_16", + steps = { + { type = "level", level = 55, name = "Reach Level 55", icon = ICON_LEVEL }, + { type = "quest", id = 40040, name = "TODO: Hateforge attunement", icon = ICON_QUEST, + desc = "Attunement for the Hateforge Quarry dungeon.", + start = "Overseer Maltorius, Flame Crest camp, northern Burning Steppes (62, 24)." }, + }, + }, + + { + id = "stormwind_vault", name = "Stormwind Vault", category = "Turtle", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Coin_01", + steps = { + { type = "level", level = 55, name = "Reach Level 55", icon = ICON_LEVEL }, + { type = "quest", id = 40050, name = "TODO: SW Vault attunement", icon = ICON_QUEST, + desc = "Attunement for the Stormwind Vault dungeon.", + start = "Warden Thelwater, Stormwind Stockade entrance, Mage Quarter, Stormwind City (50, 68)." }, + }, + }, + + ---------------------------------------------------------------- + -- DRAGONMAW RETREAT (Turtle/Octo custom, Wetlands, patch 1.18) + -- Key: "Lower Reserve Key". The scanner also identifies keyring + -- items by tooltip name, so this works even if the live client + -- assigns a different numeric id than the placeholder below. + ---------------------------------------------------------------- + { + id = "dmr", name = "Dragonmaw Retreat", category = "Turtle", faction = "Both", + icon = "Interface\\Icons\\INV_Misc_Head_Dragon_Bronze", + steps = { + { type = "level", level = 25, name = "Reach Level 25", icon = ICON_LEVEL, + desc = "Minimum level to enter Dragonmaw Retreat." }, + + { type = "quest", id = 41810, faction = "Alliance", + name = "To Crush The Dragonmaw", icon = ICON_QUEST, + desc = "Alliance intro quest for Dragonmaw Retreat.", + start = "Captain Stoutfist, Menethil Harbor, Wetlands." }, + { type = "quest", id = 41811, faction = "Alliance", + name = "Blackheart's Demise", icon = ICON_QUEST, + desc = "Alliance follow-up to slay Overlord Blackheart inside the dungeon.", + start = "Captain Stoutfist, Menethil Harbor, Wetlands." }, + + { type = "quest", id = 41820, faction = "Horde", + name = "Cavernweb Extract", icon = ICON_QUEST, + desc = "Horde intro quest — collect spider extract inside Dragonmaw Retreat.", + start = "Okul, Hammerfall, Arathi Highlands." }, + { type = "quest", id = 41821, faction = "Horde", + name = "A Blaze Unending", icon = ICON_QUEST, + desc = "Horde follow-up in Dragonmaw Retreat.", + start = "Shara Blazen, Tarren Mill, Hillsbrad Foothills." }, + + { type = "quest", id = 41830, name = "Gowlfang's Defeat", icon = ICON_QUEST, + desc = "Neutral quest — defeat the gnoll leader Gowlfang.", + start = "Grimbite, Green Belt gnoll camp, central Wetlands." }, + { type = "quest", id = 41831, name = "The Dragonmaw Brood", icon = ICON_QUEST, + desc = "Neutral quest — cull the drakes and whelps inside the retreat.", + start = "Nidiszanz, Dragonmaw Gates, Wetlands." }, + + { type = "item", id = 61810, name = "Lower Reserve Key", + icon = ICON_KEY, final = true, + desc = "Opens the gates of Dragonmaw Retreat / Dragonmaw Reserve in the Wetlands.", + start = "Looted from Dragonmaw guards in the Dragonmaw Reserve outpost, Wetlands." }, + }, + }, +} diff --git a/Locales.lua b/Locales.lua new file mode 100644 index 0000000..0d41204 --- /dev/null +++ b/Locales.lua @@ -0,0 +1,33 @@ +-- RelationshipsAttune: Locales.lua +-- Simple English string table. Add more locales by extending RelationshipsAttune_L. + +RelationshipsAttune_L = { + ADDON_NAME = "Relationships Attune", + SHORT_NAME = "RelationshipsAttune", + SLASH_HELP = "Relationships Attune commands:", + SLASH_SHOW = "/rattune show - show the window", + SLASH_HIDE = "/rattune hide - hide the window", + SLASH_TOGGLE = "/rattune - toggle the window", + SLASH_SYNC = "/rattune sync - request attunement data from your group", + SLASH_SCAN = "/rattune scan - rescan your own quest log and bags", + SLASH_RESET = "/rattune reset - wipe stored data for this character", + + MSG_LOADED = "loaded. Type /rattune for options.", + MSG_SCANNED = "Rescanned your attunements.", + MSG_SYNC_SENT = "Requested attunement data from group.", + MSG_RESET = "Character data reset.", + + UI_TITLE = "Relationships Attune", + UI_PLAYERS = "Players", + UI_STEPS = "Steps", + UI_COMPLETE = "Complete", + UI_IN_PROGRESS = "In Progress", + UI_NOT_STARTED = "Not Started", + UI_UNKNOWN = "Unknown", + UI_YOU = "You", + + STEP_QUEST = "Quest", + STEP_ITEM = "Item", + STEP_REP = "Reputation", + STEP_LEVEL = "Level", +} diff --git a/Minimap.lua b/Minimap.lua new file mode 100644 index 0000000..d258beb --- /dev/null +++ b/Minimap.lua @@ -0,0 +1,147 @@ +-- RelationshipsAttune: Minimap.lua +-- A standalone, freely-movable, hideable button (parented to UIParent so it +-- can be dragged anywhere on the screen, not just around the minimap ring). +-- Pure Lua 5.0 / WoW 1.12 safe (Turtle/Octo compatible). +-- +-- Options are stored in RelationshipsAttuneDB.options: +-- minimapX : number (x offset from UIParent CENTER, in UI pixels) +-- minimapY : number (y offset from UIParent CENTER, in UI pixels) +-- minimapHide : boolean (true = hidden) +-- Legacy angle/radius fields are migrated to X/Y on first load. + +RelationshipsAttune = RelationshipsAttune or {} +local RA = RelationshipsAttune +RA.Minimap = {} +local MM = RA.Minimap + +local BTN_NAME = "RelationshipsAttuneMinimapButton" +local ICON = "Interface\\Icons\\INV_Misc_Book_09" + +local function opts() + if not RelationshipsAttuneDB then RelationshipsAttuneDB = {} end + if not RelationshipsAttuneDB.options then RelationshipsAttuneDB.options = {} end + local o = RelationshipsAttuneDB.options + + -- Migrate legacy angle/radius (around the minimap) into free X/Y. + if type(o.minimapX) ~= "number" or type(o.minimapY) ~= "number" then + local ang = (type(o.minimapAngle) == "number") and o.minimapAngle or 200 + local rad = (type(o.minimapRadius) == "number") and o.minimapRadius or 80 + local r = ang * math.pi / 180 + local mx, my = 0, 0 + if Minimap and Minimap.GetCenter then + local cx, cy = Minimap:GetCenter() + if cx then mx, my = cx, cy end + end + local ux, uy = 0, 0 + if UIParent and UIParent.GetCenter then + local ucx, ucy = UIParent:GetCenter() + if ucx then ux, uy = ucx, ucy end + end + o.minimapX = (mx - ux) + math.cos(r) * rad + o.minimapY = (my - uy) + math.sin(r) * rad + end + if o.minimapHide == nil then o.minimapHide = false end + return o +end + +local function applyPosition(btn) + local o = opts() + btn:ClearAllPoints() + btn:SetPoint("CENTER", UIParent, "CENTER", o.minimapX, o.minimapY) +end +MM._updatePosition = applyPosition + +local function onDragUpdate() + local mx, my = GetCursorPosition() + local scale = UIParent:GetEffectiveScale() or 1 + mx = mx / scale; my = my / scale + local ucx, ucy = UIParent:GetCenter() + if not ucx then return end + local o = opts() + o.minimapX = mx - ucx + o.minimapY = my - ucy + applyPosition(this) +end + +function MM:Create() + if getglobal(BTN_NAME) then return getglobal(BTN_NAME) end + + -- Parent to UIParent so we're not clipped to the minimap area. + local btn = CreateFrame("Button", BTN_NAME, UIParent) + btn:SetWidth(31); btn:SetHeight(31) + btn:SetFrameStrata("MEDIUM") + btn:SetFrameLevel(8) + btn:EnableMouse(true) + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + btn:RegisterForDrag("LeftButton", "RightButton") + btn:SetMovable(true) + + -- Round border ring (standard minimap-button look) + local overlay = btn:CreateTexture(nil, "OVERLAY") + overlay:SetWidth(53); overlay:SetHeight(53) + overlay:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder") + overlay:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0) + + local icon = btn:CreateTexture(nil, "BACKGROUND") + icon:SetWidth(20); icon:SetHeight(20) + icon:SetTexture(ICON) + icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 7, -6) + btn.icon = icon + + btn:SetScript("OnClick", function() + if RA.UI and RA.UI.Toggle then RA.UI:Toggle() end + end) + + btn:SetScript("OnDragStart", function() + this:LockHighlight() + this:SetScript("OnUpdate", onDragUpdate) + end) + btn:SetScript("OnDragStop", function() + this:UnlockHighlight() + this:SetScript("OnUpdate", nil) + end) + + btn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_LEFT") + GameTooltip:AddLine("Relationships Attune", 1, 0.82, 0) + GameTooltip:AddLine("Left-click: open / close window", 0.9, 0.9, 0.9) + GameTooltip:AddLine("Drag: move anywhere on screen", 0.9, 0.9, 0.9) + GameTooltip:AddLine("/attune hide - hide this button", 0.6, 0.8, 1) + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() GameTooltip:Hide() end) + + applyPosition(btn) + self.btn = btn + return btn +end + +function MM:Show() + local o = opts() + o.minimapHide = false + local btn = self:Create() + btn:Show() +end + +function MM:Hide() + local o = opts() + o.minimapHide = true + if self.btn then self.btn:Hide() end +end + +function MM:Toggle() + local o = opts() + if o.minimapHide then self:Show() else self:Hide() end +end + +function MM:Refresh() + local o = opts() + local btn = self:Create() + if o.minimapHide then btn:Hide() else btn:Show() end + applyPosition(btn) +end + +function MM:Init() + self:Refresh() +end diff --git a/README.md b/README.md new file mode 100644 index 0000000..609ec09 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Relationships Attune + +A Vanilla-only attunement tracker for **Turtle WoW / Octo WoW** (client 1.12, +Lua 5.0). Same shape as the classic *Attune* addon — per-character progress, +step lists, group sync — trimmed to Vanilla content plus Turtle/Octo custom +raids. + +## Install + +Copy the folder `RelationshipsAttune/` into: + +``` +/Interface/AddOns/RelationshipsAttune/ +``` + +Make sure the folder contains `RelationshipsAttune.toc` at its root. +Restart the client (or `/reload`). + +## Commands + +``` +/rattune toggle the window +/rattune show show +/rattune hide hide +/rattune scan rescan quests + bags +/rattune sync request attunement data from your party/raid +/rattune reset wipe this character's stored data +``` + +## What it tracks + +**Vanilla:** Onyxia, Molten Core, Blackwing Lair, Naxxramas, UBRS key, +Scholomance key, AQ40 (level gate). + +**Turtle / Octo custom:** Karazhan Crypt (10), Lower Karazhan Halls (40), +Emerald Sanctum, Gilneas, Hateforge Quarry, Stormwind Vault. + +The custom entries currently use placeholder quest/item IDs marked with +`TODO:` in `Data.lua`. Replace those numbers with the real IDs from your +server and the tracker will light up automatically. + +## Adding or fixing entries + +Open `Data.lua` and edit the `RelationshipsAttune_Data` table. Each +attunement is: + +```lua +{ + id = "kara10", name = "Karazhan Crypt (10)", + category = "Turtle", faction = "Both", + steps = { + { type = "level", level = 55, name = "Level 55" }, + { type = "quest", id = 40001, name = "Crypt attunement" }, + { type = "item", id = 60001, name = "Crypt Key" }, + { type = "rep", faction = "Argent Dawn", standing = 5, name = "Honored" }, + }, +}, +``` + +Step types: `level`, `quest`, `item`, `rep`. Standing uses +`1=Hostile ... 4=Neutral, 5=Honored, 6=Revered, 7=Exalted`. + +## Group sync + +When you're in a party or raid the addon sends a compact per-attunement +summary over the addon channel (`RATTUNE` prefix). Click **Sync** in the +window or run `/rattune sync` to force an exchange. Group members without +the addon are simply ignored. + +## Notes on the 1.12 client + +- Uses only APIs available in 1.12 (`CreateFrame`, `SendAddonMessage`, + `GetContainerItemLink`, `GetQuestLogTitle`, `GetFactionInfo`). +- Registers `QUEST_TURNED_IN` defensively — Turtle backports it, real + 1.12 doesn't; without it the addon falls back to scanning the quest + log's Complete state and remembering completions in saved variables. +- No external libraries required. + +## Files + +``` +RelationshipsAttune.toc +Locales.lua +Data.lua +Core.lua +Comm.lua +UI.lua +README.md +``` + +Author: RelationshipsAttune · Version 1.0.0 diff --git a/RelationshipsAttune.toc b/RelationshipsAttune.toc new file mode 100644 index 0000000..754f518 --- /dev/null +++ b/RelationshipsAttune.toc @@ -0,0 +1,15 @@ +## Interface: 11200 +## Title: Relationships Attune +## Notes: Track Vanilla and Turtle/Octo WoW attunement progress +## Author: Relationship +## Version: 1.0 +## X-Category: Quest +## SavedVariables: RelationshipsAttuneDB +## SavedVariablesPerCharacter: RelationshipsAttuneCharDB + +Locales.lua +Data.lua +Core.lua +Comm.lua +Minimap.lua +UI.lua diff --git a/UI.lua b/UI.lua new file mode 100644 index 0000000..33e0fef --- /dev/null +++ b/UI.lua @@ -0,0 +1,711 @@ +-- RelationshipsAttune: UI.lua +-- Left raid list is a FauxScrollFrame so it never overflows the box. +-- Step rows on the right have hover tooltips (name / desc / where to start), +-- are clickable to expand extra info, and highlight green when complete. +-- Pure Lua, no XML, 1.12 / Lua 5.0 compatible (Turtle/Octo). + +RelationshipsAttune = RelationshipsAttune or {} +local RA = RelationshipsAttune +local L = RelationshipsAttune_L +RA.UI = {} +local UI = RA.UI + +-- Textures +local TEX_WHITE = "Interface\\Buttons\\WHITE8X8" +local TEX_CHECK = "Interface\\RaidFrame\\ReadyCheck-Ready" +local TEX_CROSS = "Interface\\RaidFrame\\ReadyCheck-NotReady" +local TEX_WAIT = "Interface\\RaidFrame\\ReadyCheck-Waiting" +local TEX_QMARK = "Interface\\Icons\\INV_Misc_QuestionMark" + +-- Colors +local C_DONE = { 0.20, 0.90, 0.30 } +local C_PARTIAL = { 0.95, 0.80, 0.20 } +local C_NONE = { 0.90, 0.30, 0.30 } +local C_UNKNOWN = { 0.55, 0.55, 0.55 } +local C_TURTLE = { 0.55, 0.85, 1.00 } +local C_KEYS = { 0.95, 0.75, 0.35 } + +local selectedPlayer = nil +local selectedRaid = nil + +-- Layout constants +local FRAME_W, FRAME_H = 1120, 600 +local LEFT_W = 300 +local ROW_H = 41 +local STEP_ROW_H = 28 +local STEP_EXPAND_H = 74 +local HEADER_H = 60 +local VISIBLE_RAIDS = 11 + +-- --------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------- +local function statusColor(prog) + if not prog or not prog.applicable then return C_UNKNOWN end + if prog.done then return C_DONE end + if prog.doneCount and prog.doneCount > 0 then return C_PARTIAL end + return C_NONE +end + +local function fs(parent, text, size, r, g, b, flags) + local f = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal") + if text then f:SetText(text) end + f:SetFont("Fonts\\FRIZQT__.TTF", size or 11, flags or "") + if r then f:SetTextColor(r, g, b) end + return f +end + +local function iconTex(parent, path, size) + local t = parent:CreateTexture(nil, "ARTWORK") + t:SetWidth(size); t:SetHeight(size) + if path then t:SetTexture(path) end + t:SetTexCoord(0.08, 0.92, 0.08, 0.92) + return t +end + +local function solid(parent, layer, r, g, b, a) + local t = parent:CreateTexture(nil, layer or "BACKGROUND") + t:SetTexture(TEX_WHITE) + t:SetVertexColor(r or 0, g or 0, b or 0, a or 1) + return t +end + +local function resolveStepIcon(step) + if step.icon then return step.icon end + if step.type == "item" and step.id and GetItemIcon then + local i = GetItemIcon(step.id) + if i then return i end + end + return TEX_QMARK +end + +local function categoryColor(cat) + if cat == "Turtle" then return C_TURTLE end + if cat == "Keys & Access" then return C_KEYS end + return { 1, 1, 1 } +end + +-- --------------------------------------------------------------- +-- Root frame +-- --------------------------------------------------------------- +local frame +local playerTabs = {} +local raidButtons = {} +local stepRows = {} +local detail = {} + +function UI:Build() + if frame then return end + + frame = CreateFrame("Frame", "RelationshipsAttuneFrame", UIParent) + frame:SetWidth(FRAME_W); frame:SetHeight(FRAME_H) + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + frame:SetFrameStrata("HIGH") + frame:SetMovable(true); frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() this:StartMoving() end) + frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + frame:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 }, + }) + -- Solid opaque overlay to make the window much less transparent + -- (the DialogBox background alone is quite see-through in vanilla). + frame.bgSolid = solid(frame, "BACKGROUND", 0.04, 0.05, 0.08, 0.94) + frame.bgSolid:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -13) + frame.bgSolid:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -13, 12) + -- Subtle gold-tinted top plate for the header + frame.bgHeader = solid(frame, "BACKGROUND", 0.12, 0.10, 0.06, 0.55) + frame.bgHeader:SetPoint("TOPLEFT", frame.bgSolid, "TOPLEFT", 0, 0) + frame.bgHeader:SetPoint("TOPRIGHT", frame.bgSolid, "TOPRIGHT", 0, 0) + frame.bgHeader:SetHeight(28) + frame:Hide() + + local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -6) + + -- Small title at the top left of the UI + frame.title = fs(frame, "Relationships Attunement", 11, 1, 0.82, 0, "OUTLINE") + frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -18) + + -- Player selector strip sits below the title inside the existing header + -- space; do not move the divider or columns. + frame.playerStrip = CreateFrame("Frame", nil, frame) + frame.playerStrip:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -36) + frame.playerStrip:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -32, -36) + frame.playerStrip:SetHeight(22) + + local div = solid(frame, "ARTWORK", 1, 0.82, 0, 0.35) + div:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -66) + div:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -16, -66) + div:SetHeight(1) + + -- Left column + frame.leftPane = CreateFrame("Frame", nil, frame) + frame.leftPane:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -72) + frame.leftPane:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 16, 50) + frame.leftPane:SetWidth(LEFT_W) + + -- Panel backing for the left list, dark and mostly opaque + local leftBG = solid(frame.leftPane, "BACKGROUND", 0.02, 0.03, 0.05, 0.60) + leftBG:SetAllPoints(frame.leftPane) + + frame.leftScroll = CreateFrame("ScrollFrame", "RelationshipsAttuneRaidScroll", + frame.leftPane, "FauxScrollFrameTemplate") + frame.leftScroll:SetPoint("TOPLEFT", frame.leftPane, "TOPLEFT", 0, 0) + frame.leftScroll:SetPoint("BOTTOMRIGHT", frame.leftPane, "BOTTOMRIGHT", -20, 0) + frame.leftScroll:SetScript("OnVerticalScroll", function() + FauxScrollFrame_OnVerticalScroll(ROW_H + 2, function() UI:RenderRaidList() end) + end) + frame.leftPane:EnableMouseWheel(true) + frame.leftPane:SetScript("OnMouseWheel", function() + local step = ROW_H + 2 + local off = FauxScrollFrame_GetOffset(frame.leftScroll) or 0 + local total = table.getn(RelationshipsAttune_Data) + off = off - (arg1 or 0) + if off < 0 then off = 0 end + if off > (total - VISIBLE_RAIDS) then off = total - VISIBLE_RAIDS end + if off < 0 then off = 0 end + frame.leftScroll:SetVerticalScroll(off * step) + FauxScrollFrame_OnVerticalScroll(step, function() UI:RenderRaidList() end) + end) + + -- Vertical divider + local vdiv = solid(frame, "ARTWORK", 1, 0.82, 0, 0.30) + vdiv:SetPoint("TOPLEFT", frame.leftPane, "TOPRIGHT", 4, 0) + vdiv:SetPoint("BOTTOMLEFT", frame.leftPane, "BOTTOMRIGHT", 4, 0) + vdiv:SetWidth(1) + + -- Right column + frame.rightPane = CreateFrame("Frame", nil, frame) + frame.rightPane:SetPoint("TOPLEFT", frame.leftPane, "TOPRIGHT", 14, 0) + frame.rightPane:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -16, 50) + + local rightBG = solid(frame.rightPane, "BACKGROUND", 0.02, 0.03, 0.05, 0.50) + rightBG:SetAllPoints(frame.rightPane) + + detail.icon = iconTex(frame.rightPane, TEX_QMARK, 40) + detail.icon:SetPoint("TOPLEFT", frame.rightPane, "TOPLEFT", 4, -4) + + detail.name = fs(frame.rightPane, "", 15, 1, 0.82, 0, "OUTLINE") + detail.name:SetPoint("TOPLEFT", detail.icon, "TOPRIGHT", 10, -2) + + detail.category = fs(frame.rightPane, "", 10, 0.75, 0.75, 0.75) + detail.category:SetPoint("TOPLEFT", detail.name, "BOTTOMLEFT", 0, -2) + + -- Progress bar: give it explicit left+right anchors AND a border so it + -- always has a resolvable width, then size the fill from that width. + detail.barBG = solid(frame.rightPane, "BORDER", 0, 0, 0, 0.90) + detail.barBG:SetPoint("TOPLEFT", frame.rightPane, "TOPLEFT", 4, -HEADER_H) + detail.barBG:SetPoint("TOPRIGHT", frame.rightPane, "TOPRIGHT", -4, -HEADER_H) + detail.barBG:SetHeight(18) + + -- Thin gold outline for the bar + local blt = solid(frame.rightPane, "ARTWORK", 1, 0.82, 0, 0.55) + blt:SetPoint("TOPLEFT", detail.barBG, "TOPLEFT", 0, 0) + blt:SetPoint("TOPRIGHT", detail.barBG, "TOPRIGHT", 0, 0) + blt:SetHeight(1) + local blb = solid(frame.rightPane, "ARTWORK", 1, 0.82, 0, 0.55) + blb:SetPoint("BOTTOMLEFT", detail.barBG, "BOTTOMLEFT", 0, 0) + blb:SetPoint("BOTTOMRIGHT", detail.barBG, "BOTTOMRIGHT", 0, 0) + blb:SetHeight(1) + + detail.barFill = solid(frame.rightPane, "ARTWORK", 0.20, 0.75, 0.30, 0.95) + detail.barFill:SetPoint("TOPLEFT", detail.barBG, "TOPLEFT", 1, -1) + detail.barFill:SetPoint("BOTTOMLEFT", detail.barBG, "BOTTOMLEFT", 1, 1) + detail.barFill:SetWidth(1) + + detail.barText = fs(frame.rightPane, "", 12, 1, 1, 1, "OUTLINE") + detail.barText:SetPoint("CENTER", detail.barBG, "CENTER", 0, 0) + + -- Step host + frame.stepHost = CreateFrame("Frame", nil, frame.rightPane) + frame.stepHost:SetPoint("TOPLEFT", frame.rightPane, "TOPLEFT", 4, -HEADER_H - 26) + frame.stepHost:SetPoint("BOTTOMRIGHT", frame.rightPane, "BOTTOMRIGHT", -4, 0) + -- Vanilla clients can report an unresolved height for a frame positioned by + -- two anchors during the first render. Give the expanded-step area a fixed + -- resolved height so six default-expanded rows reach the same bottom line as + -- the left pane instead of stopping early with a blank band underneath. + frame.stepHost:SetHeight(FRAME_H - 72 - 50 - HEADER_H - 26) + frame.stepHost.page = 0 + frame.stepHost.pageCount = 1 + frame.stepHost:EnableMouseWheel(true) + frame.stepHost:SetScript("OnMouseWheel", function() + -- arg1 = +1 wheel up = previous page, -1 wheel down = next page. + local delta = arg1 or 0 + local p = (frame.stepHost.page or 0) - delta + local pc = frame.stepHost.pageCount or 1 + if p < 0 then p = 0 end + if p > pc - 1 then p = pc - 1 end + frame.stepHost.page = p + UI:RenderDetail() + end) + -- Also accept wheel events over the whole right pane so the user does not + -- have to hover the exact step area. + frame.rightPane:EnableMouseWheel(true) + frame.rightPane:SetScript("OnMouseWheel", function() + local script = frame.stepHost:GetScript("OnMouseWheel") + if script then script() end + end) + + -- Bottom buttons (Sync Group removed) + local mmBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + mmBtn:SetWidth(120); mmBtn:SetHeight(22) + mmBtn:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -18, 14) + mmBtn:SetText("Toggle Minimap") + mmBtn:SetScript("OnClick", function() + if RA.Minimap then RA.Minimap:Toggle() end + end) + + local scanBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + scanBtn:SetWidth(80); scanBtn:SetHeight(22) + scanBtn:SetPoint("RIGHT", mmBtn, "LEFT", -6, 0) + scanBtn:SetText("Rescan") + scanBtn:SetScript("OnClick", function() + RA:ScanQuestLog(); RA:ScanBags(); RA:RecomputeAll(); UI:Refresh() + end) + + local hint = fs(frame, "Hover Over A Step For Details - Click To Minimise And Expand A Step. Type \"/Attune\" To Open This Panel.", + 10, 0.75, 0.75, 0.75) + hint:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 20, 20) + + -- One-shot delayed refresh: on first Show, frame widths need a tick to settle + -- (barBG:GetWidth() can return 0 the first time), so we re-render after a short delay. + frame._pending = 0 + frame:SetScript("OnUpdate", function() + if not this._needsRelayout then return end + this._pending = (this._pending or 0) + (arg1 or 0) + if this._pending >= 0.05 then + this._needsRelayout = false + this._pending = 0 + UI:Refresh() + end + end) + frame:SetScript("OnShow", function() + this._needsRelayout = true + this._pending = 0 + end) +end + +-- --------------------------------------------------------------- +-- Data helpers +-- --------------------------------------------------------------- +local function playerList() + local youLabel + if RA.playerName then + local fac = RA.playerFaction or "Neutral" + youLabel = RA.playerName .. " | " .. fac + else + youLabel = L.UI_YOU + end + local list = { { key = nil, label = youLabel, isYou = true } } + if RelationshipsAttuneDB and RelationshipsAttuneDB.players then + for key, p in pairs(RelationshipsAttuneDB.players) do + if key ~= RA.playerKey and key ~= RA.playerName then + table.insert(list, { key = key, label = p.name or key }) + end + end + end + return list +end + +local function progressFor(playerKey, attId) + if playerKey == nil then + return RelationshipsAttuneCharDB.progress[attId] + end + local p = RelationshipsAttuneDB.players[playerKey] + return p and p.progress and p.progress[attId] or nil +end + +local function isExpanded(attId, i) + local e = RelationshipsAttuneCharDB.expanded + -- Default to expanded for every step, including rows created after scroll. + -- Only an explicit false from a manual click collapses a row. + if e and e[attId] and e[attId][i] == false then return nil end + return true +end + +local function toggleExpanded(attId, i) + local e = RelationshipsAttuneCharDB.expanded + if not e[attId] then e[attId] = {} end + if isExpanded(attId, i) then + e[attId][i] = false + else + e[attId][i] = nil + end +end + +-- --------------------------------------------------------------- +-- Rendering +-- --------------------------------------------------------------- +local function renderPlayerTabs() + local list = playerList() + for i = 1, table.getn(playerTabs) do playerTabs[i]:Hide() end + + local x = 0 + for i = 1, table.getn(list) do + local entry = list[i] + local b = playerTabs[i] + if not b then + b = CreateFrame("Button", nil, frame.playerStrip) + b:SetHeight(20) + b.bg = solid(b, "BACKGROUND", 1, 1, 1, 0.10) + b.bg:SetAllPoints(b) + b.text = fs(b, "", 11, 1, 1, 1) + b.text:SetPoint("CENTER", b, "CENTER", 0, 0) + playerTabs[i] = b + end + b.entry = entry + b.text:SetText(entry.label) + local w = b.text:GetStringWidth() + 20 + b:SetWidth(w) + b:ClearAllPoints() + b:SetPoint("LEFT", frame.playerStrip, "LEFT", x, 0) + x = x + w + 4 + + local isSel = (entry.key == selectedPlayer) + if entry.isYou then + local fac = RA.playerFaction + if fac == "Alliance" then + b.bg:SetVertexColor(0.20, 0.45, 1.00, isSel and 0.55 or 0.30) + b.text:SetTextColor(0.60, 0.80, 1.00) + elseif fac == "Horde" then + b.bg:SetVertexColor(0.90, 0.15, 0.15, isSel and 0.55 or 0.30) + b.text:SetTextColor(1.00, 0.65, 0.55) + else + b.bg:SetVertexColor(1, 0.82, 0, isSel and 0.45 or 0.20) + b.text:SetTextColor(1, 0.95, 0.85) + end + else + b.bg:SetVertexColor(1, 0.82, 0, isSel and 0.45 or 0.12) + b.text:SetTextColor(1, isSel and 0.95 or 0.85, isSel and 0.6 or 0.85) + end + b:SetScript("OnClick", function() + selectedPlayer = this.entry.key + UI:Refresh() + end) + b:Show() + end +end + +function UI:RenderRaidList() + local data = RelationshipsAttune_Data + local total = table.getn(data) + + FauxScrollFrame_Update(frame.leftScroll, total, VISIBLE_RAIDS, ROW_H + 2) + local offset = FauxScrollFrame_GetOffset(frame.leftScroll) or 0 + + for i = 1, table.getn(raidButtons) do raidButtons[i]:Hide() end + + for row = 1, VISIBLE_RAIDS do + local i = row + offset + if i <= total then + local att = data[i] + local b = raidButtons[row] + if not b then + b = CreateFrame("Button", nil, frame.leftPane) + b:SetHeight(ROW_H) + b.bg = solid(b, "BACKGROUND", 1, 1, 1, 0.08) + b.bg:SetAllPoints(b) + b.sel = solid(b, "BORDER", 1, 0.82, 0, 0.25) + b.sel:SetAllPoints(b); b.sel:Hide() + b.icon = iconTex(b, TEX_QMARK, 26) + b.icon:SetPoint("LEFT", b, "LEFT", 4, 0) + b.name = fs(b, "", 12, 1, 1, 1, "OUTLINE") + b.name:SetPoint("TOPLEFT", b.icon, "TOPRIGHT", 8, -1) + b.count = fs(b, "", 10, 0.8, 0.8, 0.8) + b.count:SetPoint("BOTTOMLEFT", b.icon, "BOTTOMRIGHT", 8, 1) + b.status = b:CreateTexture(nil, "OVERLAY") + b.status:SetWidth(16); b.status:SetHeight(16) + b.status:SetPoint("RIGHT", b, "RIGHT", -6, 0) + raidButtons[row] = b + end + b:ClearAllPoints() + b:SetPoint("TOPLEFT", frame.leftPane, "TOPLEFT", 0, -(row - 1) * (ROW_H + 2)) + b:SetWidth(LEFT_W - 20) + + b.icon:SetTexture(att.icon or TEX_QMARK) + b.att = att + + local prog = progressFor(selectedPlayer, att.id) + local color = statusColor(prog) + b.name:SetText(att.name) + local nc = categoryColor(att.category) + b.name:SetTextColor(nc[1], nc[2], nc[3]) + + if prog and prog.applicable then + b.count:SetText(prog.doneCount .. " / " .. prog.total .. " steps") + b.count:SetTextColor(color[1], color[2], color[3]) + if prog.done then + b.status:SetTexture(TEX_CHECK) + b.bg:SetVertexColor(0.20, 0.90, 0.30, 0.25) + elseif (prog.doneCount or 0) > 0 then + b.status:SetTexture(TEX_WAIT) + b.bg:SetVertexColor(1, 1, 1, 0.08) + else + b.status:SetTexture(TEX_CROSS) + b.bg:SetVertexColor(1, 1, 1, 0.08) + end + b.status:Show() + else + b.count:SetText("N/A for your faction") + b.count:SetTextColor(C_UNKNOWN[1], C_UNKNOWN[2], C_UNKNOWN[3]) + b.status:Hide() + b.bg:SetVertexColor(1, 1, 1, 0.05) + end + + if selectedRaid == att.id then b.sel:Show() else b.sel:Hide() end + b:SetScript("OnClick", function() + selectedRaid = this.att.id + frame.stepHost.page = 0 + UI:Refresh() + end) + b:Show() + end + end +end + +local function stepTypeLabel(kind) + if kind == "quest" then return "Quest" + elseif kind == "item" then return "Item" + elseif kind == "rep" then return "Reputation" + elseif kind == "level" then return "Level" end + return kind or "" +end + +local function setStepTooltip(row) + row.frame:EnableMouse(true) + row.frame:SetScript("OnEnter", function() + local step = this.step + if not step then return end + GameTooltip:SetOwner(this, "ANCHOR_CURSOR") + GameTooltip:AddLine(step.name or "?", 1, 0.82, 0) + GameTooltip:AddLine(stepTypeLabel(step.type), 0.6, 0.8, 1) + if step.desc then + GameTooltip:AddLine(" ") + GameTooltip:AddLine(step.desc, 0.9, 0.9, 0.9, 1) + end + if step.start then + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Where to start:", 1, 0.82, 0) + GameTooltip:AddLine(step.start, 0.9, 0.9, 0.9, 1) + end + if step.faction then + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Faction: " .. step.faction, 0.7, 0.7, 0.7) + end + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Click to expand / collapse.", 0.5, 0.85, 1) + GameTooltip:Show() + end) + row.frame:SetScript("OnLeave", function() GameTooltip:Hide() end) + row.frame:RegisterForClicks("LeftButtonUp") + row.frame:SetScript("OnMouseUp", function() + local step = this.step + if not step or not this.attId then return end + toggleExpanded(this.attId, this.stepIndex) + UI:RenderDetail() + end) +end + +-- Compute a reliable width for the progress bar even before layout settles. +local function computeBarWidth() + local w = detail.barBG:GetWidth() + if not w or w < 10 then + local l, r = detail.barBG:GetLeft(), detail.barBG:GetRight() + if l and r then w = r - l end + end + if not w or w < 10 then + w = (frame.rightPane:GetWidth() or 0) - 8 + end + if not w or w < 10 then + w = FRAME_W - LEFT_W - 40 + end + return w +end + +function UI:RenderDetail() + local data = RelationshipsAttune_Data + if not selectedRaid then selectedRaid = data[1] and data[1].id end + local att + for i = 1, table.getn(data) do + if data[i].id == selectedRaid then att = data[i]; break end + end + if not att then return end + + detail.icon:SetTexture(att.icon or TEX_QMARK) + detail.name:SetText(att.name) + local cat = att.category + if cat == "Turtle" then + detail.category:SetText("Turtle / Octo custom content | " .. (att.faction or "Both")) + detail.category:SetTextColor(C_TURTLE[1], C_TURTLE[2], C_TURTLE[3]) + elseif cat == "Keys & Access" then + detail.category:SetText("Keys & Access (dungeon shortcut) | " .. (att.faction or "Both")) + detail.category:SetTextColor(C_KEYS[1], C_KEYS[2], C_KEYS[3]) + else + detail.category:SetText("Vanilla content | " .. (att.faction or "Both")) + detail.category:SetTextColor(0.85, 0.85, 0.85) + end + + local prog = progressFor(selectedPlayer, att.id) + local done = prog and prog.doneCount or 0 + local total = prog and prog.total or table.getn(att.steps) + local pct = (total > 0) and (done / total) or 0 + if pct < 0 then pct = 0 elseif pct > 1 then pct = 1 end + + local barW = computeBarWidth() + -- Fill uses SetWidth from the left edge. barFill is anchored to barBG's + -- TOPLEFT and BOTTOMLEFT, so its width fully controls the visible fill. + local fillW = math.max(1, (barW - 2) * pct) + detail.barFill:SetWidth(fillW) + + local c = statusColor(prog) + detail.barFill:SetVertexColor(c[1], c[2], c[3], 0.95) + detail.barText:SetText(done .. " / " .. total .. " (" .. math.floor(pct * 100 + 0.5) .. "%)") + + -- Build applicable step list + local applicable = {} + for i = 1, table.getn(att.steps) do + local step = att.steps[i] + if RA:StepApplies(step) then + table.insert(applicable, { idx = i, step = step }) + end + end + + for i = 1, table.getn(stepRows) do stepRows[i].frame:Hide() end + + -- Page-based layout: 5 steps per page, no partial rows. + local PER_PAGE = 5 + local totalSteps = table.getn(applicable) + local pageCount = math.ceil(totalSteps / PER_PAGE) + if pageCount < 1 then pageCount = 1 end + frame.stepHost.pageCount = pageCount + local page = frame.stepHost.page or 0 + if page > pageCount - 1 then page = pageCount - 1 end + if page < 0 then page = 0 end + frame.stepHost.page = page + + local startI = page * PER_PAGE + 1 + local endI = startI + PER_PAGE - 1 + if endI > totalSteps then endI = totalSteps end + + local y = 0 + + for slot = 1, PER_PAGE do + local si = startI + slot - 1 + if si > endI then + local r = stepRows[slot] + if r then r.frame:Hide() end + else + local entry = applicable[si] + local step = entry.step + local origIdx = entry.idx + local expanded = isExpanded(att.id, origIdx) + local rowH = expanded and STEP_EXPAND_H or STEP_ROW_H + + local row = stepRows[slot] + if not row then + row = {} + row.frame = CreateFrame("Button", nil, frame.stepHost) + row.bg = solid(row.frame, "BACKGROUND", 1, 1, 1, 0.06) + row.bg:SetAllPoints(row.frame) + row.icon = iconTex(row.frame, TEX_QMARK, 22) + row.icon:SetPoint("TOPLEFT", row.frame, "TOPLEFT", 4, -3) + row.type = fs(row.frame, "", 9, 0.7, 0.7, 0.7) + row.type:SetPoint("TOPLEFT", row.frame, "TOPLEFT", 36, -2) + row.name = fs(row.frame, "", 12, 1, 1, 1) + row.name:SetPoint("TOPLEFT", row.frame, "TOPLEFT", 36, -15) + row.name:SetPoint("RIGHT", row.frame, "RIGHT", -32, 0) + row.name:SetJustifyH("LEFT") + row.status = row.frame:CreateTexture(nil, "OVERLAY") + row.status:SetWidth(18); row.status:SetHeight(18) + row.status:SetPoint("TOPRIGHT", row.frame, "TOPRIGHT", -8, -4) + row.extra = fs(row.frame, "", 10, 0.90, 0.90, 0.90) + row.extra:SetPoint("TOPLEFT", row.frame, "TOPLEFT", 36, -32) + row.extra:SetPoint("TOPRIGHT", row.frame, "TOPRIGHT", -8, -32) + row.extra:SetJustifyH("LEFT") + row.extra:SetJustifyV("TOP") + setStepTooltip(row) + stepRows[slot] = row + end + row.frame.step = step + row.frame.attId = att.id + row.frame.stepIndex = origIdx + + row.frame:SetHeight(rowH) + row.frame:ClearAllPoints() + row.frame:SetPoint("TOPLEFT", frame.stepHost, "TOPLEFT", 0, y) + row.frame:SetPoint("TOPRIGHT", frame.stepHost, "TOPRIGHT", 0, y) + + row.icon:SetTexture(resolveStepIcon(step)) + row.type:SetText(stepTypeLabel(step.type)) + row.name:SetText(step.name or "?") + + local ok = prog and prog.steps and prog.steps[origIdx] + if ok then + row.status:SetTexture(TEX_CHECK); row.status:Show() + row.name:SetTextColor(C_DONE[1], C_DONE[2], C_DONE[3]) + row.bg:SetVertexColor(0.20, 0.90, 0.30, 0.28) + elseif prog and prog.applicable then + row.status:SetTexture(TEX_CROSS); row.status:Show() + row.name:SetTextColor(1, 1, 1) + row.bg:SetVertexColor(1, 1, 1, 0.06) + else + row.status:SetTexture(TEX_WAIT); row.status:Show() + row.name:SetTextColor(C_UNKNOWN[1], C_UNKNOWN[2], C_UNKNOWN[3]) + row.bg:SetVertexColor(1, 1, 1, 0.04) + end + + if expanded then + local txt = "" + if step.desc then txt = txt .. step.desc end + if step.start then + if txt ~= "" then txt = txt .. "\n\n" end + txt = txt .. "|cffffd200Where:|r " .. step.start + end + if txt == "" and step.type ~= "level" then txt = "No extra info provided." end + row.extra:SetText(txt) + row.extra:Show() + else + row.extra:SetText("") + row.extra:Hide() + end + + row.frame:Show() + + y = y - (rowH + 4) + end + end +end + +function UI:Refresh() + if not frame or not frame:IsShown() then return end + renderPlayerTabs() + self:RenderRaidList() + self:RenderDetail() +end + +function UI:Init() self:Build() end + +function UI:IsShown() + return frame and frame:IsShown() +end + +function UI:Show() + self:Build() + if not selectedRaid then + selectedRaid = RelationshipsAttune_Data[1] and RelationshipsAttune_Data[1].id + end + if frame.stepHost then frame.stepHost.page = 0 end + frame:Show() + self:Refresh() +end + +function UI:Hide() if frame then frame:Hide() end end + +function UI:Toggle() + self:Build() + if frame:IsShown() then frame:Hide() else self:Show() end +end