Add OctoQuestRewards 1.0.0 addon source

This commit is contained in:
2026-10-07 18:45:52 +00:00
parent 06e5431ff7
commit 1cc4726299
5 changed files with 4082 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
local A = OctoQuestRewards
local G = getfenv(0)
A.predictions = {}
A.frame = CreateFrame("Frame", "OctoQuestRewardsEvents")
function A.Print(text)
if DEFAULT_CHAT_FRAME then DEFAULT_CHAT_FRAME:AddMessage("|cff88ccffOQR:|r " .. text) end
end
function A.DebugRecord(r, force)
local text = "ID=" .. tostring(r.id or "?") .. " title=" .. r.title
.. " questLevel=" .. tostring(r.level or "?") .. " playerLevel=" .. r.playerLevel
.. " IDsource=" .. r.idSource .. " XPsource=" .. r.xpSource
.. " predictedXP=" .. tostring(r.xp or "unknown")
.. " predictedMoney=" .. tostring(r.money or "unknown") .. "c"
.. " moneySource=" .. tostring(r.moneySource or "unavailable")
if force or text ~= A.lastDebug then A.Print(text); A.lastDebug = text end
end
-- One deferred update per event burst, removed immediately. No idle polling.
function A.Schedule()
A.frame:SetScript("OnUpdate", function()
A.frame:SetScript("OnUpdate", nil)
A.RenderAll()
end)
end
local function Difference(actual, predicted)
if actual == nil or predicted == nil then return "unknown" end
return tostring(actual - predicted)
end
function A.Report(r, xp, money, source)
if not A.db.debug then return end
if r then A.DebugRecord(r, true) end
A.Print("actualXP=" .. tostring(xp or "unknown") .. " actualMoney=" .. tostring(money or "unknown")
.. "c differenceXP(actual-predicted)=" .. Difference(xp, r and r.xp)
.. " differenceMoney=" .. Difference(money, r and r.money) .. "c source=" .. source)
A.db.lastTurnIn = { id = r and r.id, title = r and r.title,
playerLevel = r and r.playerLevel, predictedXP = r and r.xp,
predictedMoney = r and r.money, xp = xp, money = money, source = source }
end
function A.BeginTurnIn()
if not A.db.debug then return end
local r = A.GetRecord("reward")
if r then
A.pending = { record = r, expires = GetTime() + 8 }
A.DebugRecord(r, true)
end
end
local function EscapePattern(text)
return (string.gsub(text, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"))
end
local function CaptureFormat(message, template, marker, capture)
if not template then return nil end
local pattern = EscapePattern(template)
pattern = string.gsub(pattern, EscapePattern(EscapePattern(marker)), function() return capture end)
local _, _, value = string.find(message, "^" .. pattern .. "$")
return value
end
local function ReadChatMoney(text)
local value, found = 0, false
for _, unit in ipairs({{G.GOLD, 10000}, {G.SILVER, 100}, {G.COPPER, 1}}) do
if type(unit[1]) == "string" then
local _, _, amount = string.find(string.lower(text), "(%d+)%s*" .. EscapePattern(string.lower(unit[1])))
if amount then value, found = value + tonumber(amount) * unit[2], true end
end
end
if found then return value end
end
function A.SystemMessage(message)
local p = A.pending
if not A.db.debug or not p or type(message) ~= "string" then return end
if GetTime() > p.expires then A.pending = nil; return end
local title = CaptureFormat(message, G.ERR_QUEST_COMPLETE_S, "%s", "(.-)")
if title then
if title ~= p.record.title then A.pending = nil; return end
p.confirmed = true
end
local xp = CaptureFormat(message, G.ERR_QUEST_REWARD_EXP_I, "%d", "(%d+)")
if xp then p.xp = tonumber(xp) end
local moneyText = CaptureFormat(message, G.ERR_QUEST_REWARD_MONEY_S, "%s", "(.-)")
if moneyText then p.money = ReadChatMoney(moneyText) end
if p.confirmed and (title or xp or moneyText) then
A.Report(p.record, p.xp, p.money, "server quest system messages; missing values remain unknown")
end
end
-- This Vanilla function has one argument and no return payload to preserve.
-- Wrapping is necessary for correlating server chat with an actual submission.
local function SetupHooks()
if type(G.GetQuestReward) == "function" then
local old = G.GetQuestReward
G.GetQuestReward = function(choice)
A.BeginTurnIn()
return old(choice)
end
end
if type(G.QuestLog_UpdateQuestDetails) == "function" then
if type(G.hooksecurefunc) == "function" then
G.hooksecurefunc("QuestLog_UpdateQuestDetails", A.Schedule)
else
local old = G.QuestLog_UpdateQuestDetails
-- Inspected Vanilla FrameXML: single doNotScroll argument, no returns.
G.QuestLog_UpdateQuestDetails = function(doNotScroll)
old(doNotScroll)
A.Schedule()
end
end
end
A.SetupUI()
end
local function Has(fn) return type(fn) == "function" and "yes" or "no" end
function A.Status()
local version, build = GetBuildInfo()
A.Print("1.0.0 loaded; client=" .. tostring(version) .. " build=" .. tostring(build)
.. " content=" .. tostring(G.TURTLE_WOW_VERSION or "unknown") .. "; debug=" .. tostring(A.db.debug)
.. "; XP scaling=" .. A.db.scaling .. " (unverified server match, assumed 1x)")
A.Print("NPC money API=" .. Has(G.GetRewardMoney) .. "; log money API=" .. Has(G.GetQuestLogRewardMoney)
.. "; ClassicAPI quest cache=" .. Has(C_QuestLog and C_QuestLog.GetQuestDetails)
.. "; pfQuest=" .. tostring(pfDB ~= nil) .. "; pfExtend XP=" .. tostring(PfExtend_QuestRewards ~= nil)
.. "; QUEST_TURNED_IN=" .. (A.turnedInSeen and "observed" or "not observed")
.. "; event registration=" .. tostring(A.turnInRegistered))
if A.current then A.DebugRecord(A.current, true) end
local last = A.db.lastTurnIn
if last then
A.Print("last receipt: ID=" .. tostring(last.id or "?") .. " title=" .. tostring(last.title or "?")
.. " actualXP=" .. tostring(last.xp or "unknown") .. " actualMoney=" .. tostring(last.money or "unknown")
.. "c source=" .. tostring(last.source))
end
end
local function Command(message)
local cmd = string.lower(message or "")
if cmd == "debug" then
A.db.debug = not A.db.debug
A.pending, A.lastDebug = nil, nil
A.Print("Debug " .. (A.db.debug and "enabled" or "disabled") .. ".")
A.Schedule()
elseif cmd == "status" then A.Status()
elseif cmd == "scaling vanilla" or cmd == "scaling full25" then
A.db.scaling = cmd == "scaling vanilla" and "vanilla" or "full25"
A.Print("XP scaling assumption: " .. A.db.scaling .. "; estimates remain unverified.")
A.Schedule()
else
A.Print("/oqr debug toggles turn-in comparisons; /oqr status shows sources and last receipt.")
A.Print("Optional: /oqr scaling vanilla or /oqr scaling full25. Default: Vanilla scaling, 1x rate.")
end
end
local initialized
A.frame:SetScript("OnEvent", function()
if event == "PLAYER_LOGIN" then
if initialized then return end
initialized = true
OctoQuestRewardsDB = OctoQuestRewardsDB or {}
A.db = OctoQuestRewardsDB
if A.db.scaling ~= "full25" then A.db.scaling = "vanilla" end
SLASH_OCTOQUESTREWARDS1 = "/oqr"
SlashCmdList.OCTOQUESTREWARDS = Command
SetupHooks()
A.turnInRegistered = pcall(A.frame.RegisterEvent, A.frame, "QUEST_TURNED_IN")
if C_QuestLog then pcall(A.frame.RegisterEvent, A.frame, "QUEST_DATA_LOAD_RESULT") end
A.Schedule()
elseif not initialized then return
elseif event == "QUEST_TURNED_IN" then
A.turnedInSeen = true
local id, xp, money = tonumber(arg1), tonumber(arg2), tonumber(arg3)
local r = id and A.predictions[id]
if A.pending and (not r or r.id == A.pending.record.id) then r = A.pending.record end
if not r then
r = { id = id, title = "unknown", playerLevel = UnitLevel("player"),
idSource = "QUEST_TURNED_IN", xpSource = "unavailable" }
end
A.Report(r, xp, money, "QUEST_TURNED_IN server completion packet")
A.pending = nil
elseif event == "CHAT_MSG_SYSTEM" then A.SystemMessage(arg1)
elseif event == "QUEST_FINISHED" or event == "QUEST_GREETING" then
A.current = nil
A.Schedule()
else A.Schedule() end
end)
for _, name in ipairs({"PLAYER_LOGIN", "QUEST_DETAIL", "QUEST_PROGRESS", "QUEST_COMPLETE",
"QUEST_FINISHED", "QUEST_GREETING", "QUEST_ITEM_UPDATE", "QUEST_LOG_UPDATE", "PLAYER_LEVEL_UP",
"CHAT_MSG_SYSTEM", "UI_SCALE_CHANGED"}) do
pcall(A.frame.RegisterEvent, A.frame, name)
end
+12
View File
@@ -0,0 +1,12 @@
## Interface: 11200
## Title: OctoQuestRewards
## Notes: Quest XP estimates in the original quest UI, with optional reward debugging.
## Author: John The Baptist
## Version: 1.0.0
## OptionalDeps: pfQuest, pfQuest-turtle, pfExtend, ClassicAPI, AI_VoiceOver
## SavedVariablesPerCharacter: OctoQuestRewardsDB
QuestData.lua
VanillaXP.lua
UI.lua
Core.lua
+189
View File
@@ -0,0 +1,189 @@
OctoQuestRewards = {}
local A = OctoQuestRewards
local G = getfenv(0)
-- A raw custom base XP cross-check, not the website's potentially scaled Gains.
-- https://octowow.st/db/?quest=41677 (RewXP=2150, level=32, checked 2026-10-05)
local octoBase = { [41677] = {32, 2150} }
-- Only documented, present functions are called. No modern XP API is assumed.
function A.Call(fn, a)
if type(fn) ~= "function" then return nil end
local ok, value
if a == nil then ok, value = pcall(fn) else ok, value = pcall(fn, a) end
if ok then return value end
end
local function QuestTables()
local q = pfDB and pfDB.quests
return q and q.data, q and (q.loc or q[GetLocale()] or q.enUS)
end
local titleIndex, indexedLocale
local function MatchTitle(title, level, description, objectives)
local data, loc = QuestTables()
if not data or not loc or not title then return nil end
if indexedLocale ~= loc then
titleIndex, indexedLocale = {}, loc
for id, row in pairs(loc) do
if type(row) == "table" and row.T and data[id] then
titleIndex[row.T] = titleIndex[row.T] or {}
table.insert(titleIndex[row.T], id)
end
end
end
local found, count = nil, 0
for _, id in ipairs(titleIndex[title] or {}) do
local row = data[id]
if not level or level == row.lvl or row.lvl == -1 then
found, count = id, count + 1
end
end
if count == 1 then return found, "pfQuest title/level match (inferred ID)" end
-- Duplicate titles need exact quest text; never pick the first guessed ID.
found, count = nil, 0
if pfDatabase and pfDatabase.FormatQuestText then
for _, id in ipairs(titleIndex[title] or {}) do
local row = loc[id]
local d = row.D and pfDatabase:FormatQuestText(row.D)
local o = row.O and pfDatabase:FormatQuestText(row.O)
if (not level or level == data[id].lvl or data[id].lvl == -1)
and ((description and description ~= "" and description == d)
or (objectives and objectives ~= "" and objectives == o)) then
found, count = id, count + 1
end
end
end
if count == 1 then return found, "pfQuest exact text match (inferred ID)" end
end
local function PositiveID(id)
id = tonumber(id)
if id and id > 0 then return id end
end
function A.LogTitle(index)
if pfQuestCompat and pfQuestCompat.GetQuestLogTitle then
return pfQuestCompat.GetQuestLogTitle(index)
end
return GetQuestLogTitle(index)
end
function A.LogID(index, title, level, description, objectives)
if C_QuestLog and C_QuestLog.GetQuestIDForLogIndex then
local id = PositiveID(A.Call(C_QuestLog.GetQuestIDForLogIndex, index))
if id then return id, "C_QuestLog.GetQuestIDForLogIndex" end
end
local link = A.Call(G.GetQuestLink, index)
if type(link) == "string" then
local _, _, id = string.find(link, "quest:(%d+)")
if PositiveID(id) then return tonumber(id), "GetQuestLink" end
end
return MatchTitle(title, level, description, objectives)
end
-- Vanilla degradation. Octo's server rate/scaling is not verified.
-- Level alone cannot determine base XP: quests at the same level vary widely.
function A.EstimateXP(base, questLevel, playerLevel)
if type(base) ~= "number" or not questLevel then return nil end
if playerLevel >= 60 then return 0 end
if questLevel < 1 then questLevel = playerLevel end
local difference = playerLevel - questLevel
local factor = 1
if A.db.scaling == "full25" then
-- Optional explicit assumption for Turtle-like custom scaling.
if difference > 25 then return nil end
elseif difference >= 10 then factor = 0.1
elseif difference > 5 then factor = (10 - difference) * 0.2 end
return math.ceil(base * factor - 0.000001)
end
function A.GetRecord(kind)
local r = { playerLevel = UnitLevel("player"), kind = kind }
local index
if kind == "log" then
index = GetQuestLogSelection()
local header
local tag
r.title, r.level, tag, header = A.LogTitle(index)
if not r.title or header then return nil end
local description, objectives = GetQuestLogQuestText()
r.id, r.idSource = A.LogID(index, r.title, r.level, description, objectives)
r.money = A.Call(G.GetQuestLogRewardMoney)
r.moneySource = "GetQuestLogRewardMoney (server cache)"
else
r.title = GetTitleText()
if not r.title or r.title == "" then return nil end
-- VoiceOver installs GetQuestID inside its own namespace, not the engine.
r.id = PositiveID(A.Call(G.GetQuestID))
if r.id then r.idSource = "GetQuestID (provider unverified)" end
if not r.id then
r.id, r.idSource = MatchTitle(r.title, nil,
kind == "detail" and GetQuestText() or nil,
kind == "detail" and GetObjectiveText() or nil)
end
if kind == "progress" then
-- REQUEST_ITEMS packets don't supply reward money: GetRewardMoney here
-- can be stale. Read only a uniquely matching accepted quest instead.
local match, count = nil, 0
for i = 1, GetNumQuestLogEntries() do
local title, level, tag, header = A.LogTitle(i)
if title == r.title and not header then match, count = i, count + 1 end
end
if count == 1 then
local old = GetQuestLogSelection()
SelectQuestLogEntry(match)
local log = A.GetRecord("log")
SelectQuestLogEntry(old)
if log then
r.id, r.idSource, r.level = log.id, log.idSource, log.level
r.money, r.moneySource = log.money, log.moneySource
end
end
else
r.money = A.Call(G.GetRewardMoney)
r.moneySource = "GetRewardMoney (NPC server packet)"
end
end
local data = QuestTables()
if not r.level and data and r.id and data[r.id] then r.level = data[r.id].lvl end
local cache
if r.id and C_QuestLog and C_QuestLog.GetQuestDetails then
cache = A.Call(C_QuestLog.GetQuestDetails, r.id)
if type(cache) == "table" then
r.level = cache.level or r.level
if r.money == nil and r.playerLevel < 60 then
r.money, r.moneySource = cache.rewardMoney, "C_QuestLog.GetQuestDetails.rewardMoney"
end
end
end
if r.money and r.money < 0 then r.money = 0 end -- A cost isn't a reward.
local reward = r.id and PfExtend_QuestRewards and PfExtend_QuestRewards[r.id]
local baseline = r.id and (octoBase[r.id] or (A.VanillaXP and A.VanillaXP[r.id]))
local base
if baseline and (baseline[1] == r.level or baseline[1] == -1) then
base = baseline[2]
r.xpSource = (octoBase[r.id] and "Octo DB raw RewXP" or "LibQuestXP Vanilla baseline")
.. " + " .. A.db.scaling .. " scaling; rate assumed 1x; Octo match unverified"
elseif not reward and type(cache) == "table" and type(cache.rewardMoneyAtMaxLevel) == "number" then
-- Classic cache has no XP field. This is the historical Vanilla conversion,
-- NOT a directly supplied XP value, and may differ on custom quests.
if r.level and r.level <= 60 then
base = cache.rewardMoneyAtMaxLevel / 0.6
r.xpSource = "ClassicAPI max-level money / 0.6 + " .. A.db.scaling .. " scaling (estimated)"
end
end
r.baseXP = base
r.xp = A.EstimateXP(base, r.level, r.playerLevel)
if base == nil and reward and type(reward.xp) == "number" then
-- The installed pfExtend scraper copies displayed Gains, not raw RewXP.
-- We cannot undo unknown website scaling. Use it only as a near-level
-- reference, with no second scaling; suppress it on overlevelled quests.
r.xpSource = "pfExtend website Gains reference; base XP/scaling unverified"
if r.playerLevel >= 60 then r.xp = 0
elseif r.level and (r.level < 1 or r.playerLevel <= r.level + 5) then r.xp = reward.xp end
end
r.idSource = r.idSource or "unresolved/ambiguous ID"
r.xpSource = r.xpSource or "unavailable"
if r.id then A.predictions[r.id] = r end
return r
end
+146
View File
@@ -0,0 +1,146 @@
local A = OctoQuestRewards
local G = getfenv(0)
local panels = {
detail = { "QuestFrameDetailPanel", "QuestDetailScrollChildFrame", "QuestDetailScrollFrame", "QuestDescription", "QuestDetailRewardTitleText" },
progress = { "QuestFrameProgressPanel", "QuestProgressScrollChildFrame", "QuestProgressScrollFrame", "QuestProgressText" },
reward = { "QuestFrameRewardPanel", "QuestRewardScrollChildFrame", "QuestRewardScrollFrame", "QuestRewardText", "QuestRewardRewardTitleText" },
log = { "QuestLogFrame", "QuestLogDetailScrollChildFrame", "QuestLogDetailScrollFrame", "QuestLogQuestDescription", "QuestLogRewardTitleText" },
}
-- Copy the live font, including addon overrides, rather than just its template.
local function CopyFont(target, source)
if not source or not source.GetFont then return end
local font, size, flags = source:GetFont()
if font and size then target:SetFont(font, size, flags or "") end
end
function A.Number(value)
local s = tostring(math.floor(value))
local count
repeat s, count = string.gsub(s, "^(%-?%d+)(%d%d%d)", "%1,%2") until count == 0
return s
end
function A.Money(copper)
copper = math.floor(copper)
local gold = math.floor(copper / 10000)
local silver = math.floor(copper / 100) - gold * 100
local coins = copper - math.floor(copper / 100) * 100
local parts = {}
if gold > 0 then table.insert(parts, gold .. "g") end
if silver > 0 then table.insert(parts, silver .. "s") end
if coins > 0 then table.insert(parts, coins .. "c") end
return table.concat(parts, " ")
end
-- Read actual visible content extents, including the nested fading reward area.
-- Ignore textures and spacer frames, whose bounds aren't content bounds.
local function ContentBottom(object, block, top, scale, depth)
if object == block or not object:IsShown() or depth > 5 then return 0 end
local name = object:GetName() or ""
if string.find(name, "Spacer") then return 0 end
local bottom = 0
local isText = object:IsObjectType("FontString")
if (isText and object:GetText() and object:GetText() ~= "")
or object:IsObjectType("Button") or string.find(name, "MoneyFrame$") then
local b = object:GetBottom()
if b then
-- FontStrings inherit their parent's scale; don't assume Frame-only
-- methods exist on Vanilla regions.
local owner = isText and object:GetParent() or object
bottom = (top * scale - b * owner:GetEffectiveScale()) / scale
end
end
if object.GetRegions then
for _, region in ipairs({object:GetRegions()}) do
bottom = math.max(bottom, ContentBottom(region, block, top, scale, depth + 1))
end
end
if object.GetChildren then
for _, child in ipairs({object:GetChildren()}) do
bottom = math.max(bottom, ContentBottom(child, block, top, scale, depth + 1))
end
end
return bottom
end
function A.Render(kind)
local cfg = panels[kind]
local panel, child, scroll = G[cfg[1]], G[cfg[2]], G[cfg[3]]
if not panel or not child or not scroll then return end
if cfg.block then cfg.block:Hide() end
if not panel:IsVisible() then return end
local r = A.GetRecord(kind)
A.current = r
if A.db.debug and r then A.DebugRecord(r) end
local lines = {}
if r and r.xp and r.xp > 0 then table.insert(lines, "Estimated XP: " .. A.Number(r.xp)) end
if not cfg.block then
cfg.block = CreateFrame("Frame", nil, child)
cfg.block:SetWidth(child:GetWidth() - 15)
cfg.block.title = cfg.block:CreateFontString(nil, "ARTWORK", "QuestTitleFont")
cfg.block.title:SetPoint("TOPLEFT", cfg.block, "TOPLEFT", 0, 0)
cfg.block.title:SetText("Rewards")
cfg.block.text = cfg.block:CreateFontString(nil, "ARTWORK", "QuestFont")
cfg.block.text:SetJustifyH("LEFT")
cfg.baseHeight = child:GetHeight()
end
local block = cfg.block
-- Keep our previous extension out of the next layout calculation.
if cfg.lastHeight and child:GetHeight() == cfg.lastHeight then child:SetHeight(cfg.baseHeight) end
if table.getn(lines) == 0 then
block:Hide()
scroll:UpdateScrollChildRect()
return
end
local top, scale = child:GetTop(), child:GetEffectiveScale()
if not top then block:Hide(); return end
local bottom = ContentBottom(child, block, top, scale, 0)
local heading = cfg[5] and G[cfg[5]]
local example = G[cfg[4]]
CopyFont(block.text, example)
CopyFont(block.title, heading or G.QuestProgressTitleText)
local titleHeight = 0
if heading and heading:IsShown() then block.title:Hide()
else
block.title:Show()
titleHeight = block.title:GetHeight() + 5
end
block:ClearAllPoints()
block:SetPoint("TOPLEFT", child, "TOPLEFT", 5, -bottom - 12)
block:SetWidth(child:GetWidth() - 15)
block.text:ClearAllPoints()
block.text:SetPoint("TOPLEFT", block, "TOPLEFT", 0, -titleHeight)
block.text:SetWidth(block:GetWidth())
block.text:SetText(table.concat(lines, "\n"))
if example and example.GetTextColor then
block.text:SetTextColor(example:GetTextColor())
else block.text:SetTextColor(0, 0, 0) end
if heading and heading.GetTextColor then block.title:SetTextColor(heading:GetTextColor()) end
block:SetHeight(titleHeight + block.text:GetHeight())
cfg.lastHeight = math.max(cfg.baseHeight, bottom + 12 + block:GetHeight() + 12)
child:SetHeight(cfg.lastHeight)
block:Show()
scroll:UpdateScrollChildRect()
end
function A.RenderAll()
for kind in pairs(panels) do A.Render(kind) end
end
function A.HookScript(frame, script, callback)
if not frame then return end
if frame.HookScript then frame:HookScript(script, callback); return end
-- Vanilla has no HookScript. Chain the existing script with its original args.
local old = frame:GetScript(script)
frame:SetScript(script, function(...)
if old then old(unpack(arg)) end
callback()
end)
end
function A.SetupUI()
for _, cfg in pairs(panels) do
A.HookScript(G[cfg[1]], "OnShow", A.Schedule)
end
end
+3541
View File
File diff suppressed because it is too large Load Diff