commit 510a991daf8273bed3763a72fcb37477ce2ffff1 Author: Relationship <139162359+Relationship-OctoWoW@users.noreply.github.com> Date: Thu Jul 9 17:30:26 2026 +0100 Add files via upload 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