Files
RelationshipsAttune/Core.lua
T
2026-07-09 17:30:26 +01:00

651 lines
24 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.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 <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")
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)