Files
RelationshipsAttune/Core.lua
T
2026-07-12 16:58:48 +01:00

703 lines
25 KiB
Lua

-- 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.4.0"
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 = 10 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.announced then RelationshipsAttuneCharDB.announced = {} end
if not RelationshipsAttuneCharDB.progress then RelationshipsAttuneCharDB.progress = {} end
if not RelationshipsAttuneCharDB.expanded then RelationshipsAttuneCharDB.expanded = {} end
end
-- ---------------------------------------------------------------
-- Quest log scan (vanilla API)
--
-- Note: vanilla 1.12's GetQuestLogTitle returns 6 values without a questId.
-- Turtle/Octo backported questId as the 8th return. We pcall + accept both
-- shapes so the addon degrades gracefully on stock 1.12.
-- ---------------------------------------------------------------
function RA:ScanQuestLog()
if not GetNumQuestLogEntries then return end
-- Rebuild transient quest states from the live log so that abandoning a
-- quest reverts its step colour. "turnedin" is authoritative and preserved.
local kept = {}
for qid, st in pairs(RelationshipsAttuneCharDB.quests) do
if st == "turnedin" then kept[qid] = "turnedin" end
end
RelationshipsAttuneCharDB.quests = kept
RA:BuildNameIndex()
local qNameMap = RA._questNameToId or {}
local numEntries = GetNumQuestLogEntries()
for i = 1, numEntries do
local ok, title, level, tag, isHeader, _, isComplete, _, questId =
pcall(GetQuestLogTitle, i)
if ok and (not isHeader) then
-- Prefer the Turtle/Octo backported questId; fall back to matching
-- the quest title against our data so stock 1.12 (no questId return)
-- and any titles the client failed to tag still get credited.
local qid = questId
if (not qid) and title then
qid = qNameMap[string.lower(title)]
end
if qid and RelationshipsAttuneCharDB.quests[qid] ~= "turnedin" then
if isComplete and isComplete == 1 then
RelationshipsAttuneCharDB.quests[qid] = "complete"
else
RelationshipsAttuneCharDB.quests[qid] = "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.
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 and RA._questNameToId then return RA._nameToId end
local map, qmap = {}, {}
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.name and s.id then
if s.type == "item" then
map[string.lower(s.name)] = s.id
elseif s.type == "quest" then
qmap[string.lower(s.name)] = s.id
end
end
end
end
end
end
RA._nameToId = map
RA._questNameToId = qmap
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
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
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)
local link
local okL, l = pcall(GetContainerItemLink, bag, slot)
if okL then link = l end
if link then rememberItemLink(link) end
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
if GetContainerItemInfo then
pcall(GetContainerItemInfo, bag, slot)
end
end
pcall(function() tip:Hide() end)
end
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
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
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)
-- Returns two values: done (boolean), inProgress (boolean).
-- inProgress = the player is actively working on this step but has not
-- yet finished it (e.g. quest accepted but not complete).
if step.type == "level" then
return (UnitLevel("player") or 0) >= (step.level or 0), false
elseif step.type == "quest" then
local st = RelationshipsAttuneCharDB.quests[step.id]
if st == "complete" or st == "turnedin" then
return true, false
elseif st == "inlog" then
return false, true
end
return false, false
elseif step.type == "item" then
return RelationshipsAttuneCharDB.items[step.id] == true, false
elseif step.type == "rep" then
local need = DATA_STANDING_MAP[step.standing] or step.standing or 5
local have = self:GetReputationStanding(step.faction)
if have >= need then return true, false end
-- Any positive rep with the faction below the required standing counts
-- as in-progress so the player sees they are working on it.
if have and have >= 4 then return false, true end
return false, false
end
return false, 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, stepsInProgress, 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 doneVal, ipVal = self:EvaluateStep(step)
local ok = doneVal and true or false
steps[i] = ok
stepsInProgress[i] = (not ok) and (ipVal and true or false) or false
total = total + 1
if ok then doneCount = doneCount + 1 end
else
steps[i] = nil
stepsInProgress[i] = nil
end
end
-- Short-circuit: if the "final" step is complete, treat the whole
-- attunement as done. Prefer an explicit final=true step; fall back to
-- the last item step (key), then the last applicable step.
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
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
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,
stepsInProgress= stepsInProgress,
doneCount = doneCount,
total = total,
}
end
function RA:AnnounceCompletion(att)
if not att or not att.name then return end
if not RelationshipsAttuneCharDB.announced then
RelationshipsAttuneCharDB.announced = {}
end
if RelationshipsAttuneCharDB.announced[att.id] then return end
RelationshipsAttuneCharDB.announced[att.id] = true
local inGuild = false
if IsInGuild then
local ok, res = pcall(IsInGuild)
if ok and res then inGuild = true end
end
if not inGuild and GetGuildInfo then
local ok, gname = pcall(GetGuildInfo, "player")
if ok and gname and gname ~= "" then inGuild = true end
end
local msg = "Attunement Complete: " .. (RA.playerName or "I")
.. " has fully completed the " .. att.name .. " attunement!"
if inGuild and SendChatMessage then
pcall(SendChatMessage, msg, "GUILD")
end
RA.print(msg)
end
function RA:RecomputeAll()
for i = 1, table.getn(RelationshipsAttune_Data) do
local att = RelationshipsAttune_Data[i]
local prev = RelationshipsAttuneCharDB.progress[att.id]
local wasDone = prev and prev.done
local prog = self:EvaluateAttunement(att)
RelationshipsAttuneCharDB.progress[att.id] = prog
if prog.applicable and prog.done and not wasDone then
self:AnnounceCompletion(att)
end
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._periodic = 0
RA._dirty = false
RA._minRescan = 1.5
function RA:MarkDirty() RA._dirty = true end
-- Full periodic rescan (quest log + bags + recompute + UI refresh).
function RA:AutoScan()
RA:ScanQuestLog()
RA:ScanBags()
RA:RecomputeAll()
if RA.UI and RA.UI.Refresh then RA.UI:Refresh() end
end
function RA:_OnUpdate(elapsed)
elapsed = elapsed or 0
RA._throttle = RA._throttle + elapsed
RA._periodic = RA._periodic + elapsed
local interval = (RelationshipsAttuneDB and RelationshipsAttuneDB.options
and RelationshipsAttuneDB.options.autoScanSeconds) or 10
if interval < 3 then interval = 3 end
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
-- Event-driven rescan: something changed, catch up quickly.
if RA._dirty and RA._throttle >= RA._minRescan then
RA._dirty = false
RA._throttle = 0
RA._periodic = 0
RA:AutoScan()
return
end
-- Unconditional periodic rescan so state stays fresh even if no events fire
-- (e.g. reputation ticks from mob kills, quest turn-ins on stock 1.12
-- without QUEST_TURNED_IN, background item pickups by other addons).
if RA._periodic >= interval then
RA._periodic = 0
RA._throttle = 0
RA:AutoScan()
end
end
-- ---------------------------------------------------------------
-- Slash commands
-- ---------------------------------------------------------------
local function slashHandler(msg)
msg = string.lower(msg or "")
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 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)
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 <id>- 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")
-- These events don't exist on stock 1.12 but Turtle/Octo backports them.
-- pcall prevents a hard failure on builds that don't recognise them.
pcall(function() ef:RegisterEvent("QUEST_TURNED_IN") end)
pcall(function() ef:RegisterEvent("KEYRING_UPDATE") end)
pcall(function() ef:RegisterEvent("UNIT_INVENTORY_CHANGED") end)
pcall(function() ef:RegisterEvent("ITEM_PUSH") end)
pcall(function() ef:RegisterEvent("PLAYER_XP_UPDATE") end)
pcall(function() ef:RegisterEvent("ZONE_CHANGED_NEW_AREA") end)
pcall(function() ef:RegisterEvent("SKILL_LINES_CHANGED") 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()
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"
or e == "UNIT_INVENTORY_CHANGED"
or e == "ITEM_PUSH"
or e == "PLAYER_XP_UPDATE"
or e == "ZONE_CHANGED_NEW_AREA"
or e == "SKILL_LINES_CHANGED" 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)