mirror of
https://github.com/Blaststar/Questie-Octo-Vanilla.git
synced 2026-10-26 07:00:49 +00:00
Quest tooltip fixes and addition of theme switch
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
QuestieOcto.Objectives = QuestieOcto.Objectives or {}
|
||||
local O = QuestieOcto.Objectives
|
||||
|
||||
O.byQuest={}
|
||||
O.ready=false
|
||||
O.running=false
|
||||
O.pending=false
|
||||
O.generation=0
|
||||
O.stats={
|
||||
quests=0, creature=0, object=0, item=0, itemSources=0, irTargets=0,
|
||||
mapped=0, unmapped=0, direct=0, fallback=0, fuzzy=0, single=0, typeMismatch=0,
|
||||
rebuilds=0, dependencyWaits=0
|
||||
}
|
||||
|
||||
local MIN_DROP_CHANCE=1
|
||||
|
||||
local function ResetStats()
|
||||
local rebuilds=O.stats.rebuilds or 0
|
||||
local waits=O.stats.dependencyWaits or 0
|
||||
O.stats={
|
||||
quests=0, creature=0, object=0, item=0, itemSources=0, irTargets=0,
|
||||
mapped=0, unmapped=0, direct=0, fallback=0, fuzzy=0, single=0, typeMismatch=0,
|
||||
rebuilds=rebuilds, dependencyWaits=waits
|
||||
}
|
||||
end
|
||||
|
||||
local function DependenciesReady()
|
||||
return QuestieOcto.DatabaseAPI and QuestieOcto.DatabaseAPI:IsReady()
|
||||
and QuestieOcto.QuestLog and QuestieOcto.QuestLog.snapshot
|
||||
end
|
||||
|
||||
local function AddSource(list,kind,id,itemID,chance,vendor)
|
||||
table.insert(list,{kind=kind,id=id,itemID=itemID,chance=chance,vendor=vendor and true or false})
|
||||
end
|
||||
|
||||
local function AddUniqueSource(list,seen,kind,id,itemID,chance,vendor)
|
||||
local key=kind..":"..tostring(id)
|
||||
if seen[key] then return end
|
||||
seen[key]=true
|
||||
AddSource(list,kind,id,itemID,chance,vendor)
|
||||
O.stats.itemSources=O.stats.itemSources+1
|
||||
end
|
||||
|
||||
-- pfQuest SearchItemID semantics: direct creature/object drops above the
|
||||
-- configured threshold, reference-loot owners, and vendors. The original
|
||||
-- pfQuest default threshold is 1%, which we preserve here without adding a
|
||||
-- new player-facing option.
|
||||
local function ResolveItemSources(itemID)
|
||||
local result={}
|
||||
local seen={}
|
||||
local sources=QuestieOcto.DatabaseAPI:GetItemSources(itemID)
|
||||
|
||||
if sources and sources.Creature then
|
||||
for creatureID,chance in pairs(sources.Creature) do
|
||||
chance=tonumber(chance) or 0
|
||||
if chance>=MIN_DROP_CHANCE then
|
||||
AddUniqueSource(result,seen,"creature",creatureID,itemID,chance,false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if sources and sources.GameObject then
|
||||
for objectID,chance in pairs(sources.GameObject) do
|
||||
chance=tonumber(chance) or 0
|
||||
if chance>=MIN_DROP_CHANCE and chance>0 then
|
||||
AddUniqueSource(result,seen,"gameObject",objectID,itemID,chance,false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if sources and sources.Reference then
|
||||
for refID,chance in pairs(sources.Reference) do
|
||||
chance=tonumber(chance) or 0
|
||||
if chance>=MIN_DROP_CHANCE then
|
||||
local ref=QuestieOcto.DatabaseAPI:GetReferenceLootRaw(refID)
|
||||
if ref and ref["U"] then
|
||||
for creatureID in pairs(ref["U"]) do
|
||||
AddUniqueSource(result,seen,"creature",creatureID,itemID,chance,false)
|
||||
end
|
||||
end
|
||||
if ref and ref["O"] then
|
||||
for objectID in pairs(ref["O"]) do
|
||||
AddUniqueSource(result,seen,"gameObject",objectID,itemID,chance,false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if sources and sources.Vendor then
|
||||
for creatureID in pairs(sources.Vendor) do
|
||||
AddUniqueSource(result,seen,"creature",creatureID,itemID,nil,true)
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
local function ItemIDFromLink(link)
|
||||
if not link then return nil end
|
||||
local _,_,id=string.find(link,"item:(%d+)")
|
||||
return tonumber(id)
|
||||
end
|
||||
|
||||
local function PlayerHasItem(itemID)
|
||||
itemID=tonumber(itemID)
|
||||
if not itemID then return false end
|
||||
|
||||
if GetContainerNumSlots and GetContainerItemLink then
|
||||
for bag=0,4 do
|
||||
local slots=GetContainerNumSlots(bag) or 0
|
||||
for slot=1,slots do
|
||||
if ItemIDFromLink(GetContainerItemLink(bag,slot))==itemID then return true end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if GetInventoryItemLink then
|
||||
for slot=1,19 do
|
||||
if ItemIDFromLink(GetInventoryItemLink("player",slot))==itemID then return true end
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
-- pfQuest only reacts to bag changes that affect registered quest-item
|
||||
-- dependencies. Normal objective counts refresh the Tracker directly; map
|
||||
-- objectives only rebuild when their todo/done semantics actually change.
|
||||
local function CollectIRItemIDs()
|
||||
local tracked={}
|
||||
for questID in pairs(QuestieOcto.QuestLog.active or {}) do
|
||||
local q=QuestieOcto.QuestModel:Get(questID)
|
||||
if q and q.objectives and q.objectives.irItems then
|
||||
for _,itemID in pairs(q.objectives.irItems) do
|
||||
itemID=tonumber(itemID)
|
||||
if itemID and itemID>0 then tracked[itemID]=true end
|
||||
end
|
||||
end
|
||||
end
|
||||
return tracked
|
||||
end
|
||||
|
||||
local function SnapshotIRPresence()
|
||||
local state={}
|
||||
local tracked=CollectIRItemIDs()
|
||||
for itemID in pairs(tracked) do
|
||||
state[itemID]=PlayerHasItem(itemID) and true or false
|
||||
end
|
||||
return state
|
||||
end
|
||||
|
||||
local function RefreshIRPresence()
|
||||
O.irPresence=SnapshotIRPresence()
|
||||
end
|
||||
|
||||
local function IRPresenceChanged()
|
||||
local previous=O.irPresence or {}
|
||||
local current=SnapshotIRPresence()
|
||||
|
||||
for itemID,present in pairs(current) do
|
||||
if previous[itemID]~=present then
|
||||
O.irPresence=current
|
||||
return true
|
||||
end
|
||||
end
|
||||
for itemID in pairs(previous) do
|
||||
if current[itemID]==nil then
|
||||
O.irPresence=current
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
O.irPresence=current
|
||||
return false
|
||||
end
|
||||
|
||||
local function BuildIRTargets(q)
|
||||
local targets={}
|
||||
local skipCreature={}
|
||||
local skipObject={}
|
||||
|
||||
for _,itemID in pairs(q.objectives.irItems or {}) do
|
||||
local requirement=QuestieOcto.DatabaseAPI:GetQuestItemRequirementRaw(itemID)
|
||||
if requirement then
|
||||
local hasItem=PlayerHasItem(itemID)
|
||||
for signedID in pairs(requirement) do
|
||||
signedID=tonumber(signedID)
|
||||
if signedID and signedID<0 then
|
||||
local id=math.abs(signedID)
|
||||
skipObject[id]=true
|
||||
if hasItem then
|
||||
table.insert(targets,{kind="gameObject",id=id,itemID=itemID,ir=true})
|
||||
end
|
||||
elseif signedID and signedID>0 then
|
||||
skipCreature[signedID]=true
|
||||
if hasItem then
|
||||
table.insert(targets,{kind="creature",id=signedID,itemID=itemID,ir=true})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return targets,skipCreature,skipObject
|
||||
end
|
||||
|
||||
local function NormalizeLogType(typ)
|
||||
typ=string.lower(tostring(typ or ""))
|
||||
if typ=="monster" or typ=="creature" or typ=="kill" then return "creature" end
|
||||
if typ=="item" then return "item" end
|
||||
if typ=="object" or typ=="gameobject" then return "gameObject" end
|
||||
return typ
|
||||
end
|
||||
|
||||
local function Levenshtein(str1,str2)
|
||||
str1=string.lower(tostring(str1 or ""))
|
||||
str2=string.lower(tostring(str2 or ""))
|
||||
local len1=string.len(str1)
|
||||
local len2=string.len(str2)
|
||||
local matrix={}
|
||||
local cost=0
|
||||
if len1==0 then return len2 end
|
||||
if len2==0 then return len1 end
|
||||
if str1==str2 then return 0 end
|
||||
for i=0,len1 do matrix[i]={}; matrix[i][0]=i end
|
||||
for j=0,len2 do matrix[0][j]=j end
|
||||
for i=1,len1 do
|
||||
for j=1,len2 do
|
||||
if string.byte(str1,i)==string.byte(str2,j) then cost=0 else cost=1 end
|
||||
matrix[i][j]=math.min(matrix[i-1][j]+1,matrix[i][j-1]+1,matrix[i-1][j-1]+cost)
|
||||
end
|
||||
end
|
||||
return matrix[len1][len2]
|
||||
end
|
||||
|
||||
local function MergeState(entry,row)
|
||||
entry.objectiveIndex=row.index
|
||||
entry.objectiveText=row.text
|
||||
entry.rawObjectiveText=row.rawText or row.text
|
||||
entry.objectiveType=row.type
|
||||
entry.current=row.current
|
||||
entry.required=row.required
|
||||
entry.complete=row.complete and true or false
|
||||
return entry
|
||||
end
|
||||
|
||||
local function ObjectiveTypesMatch(logType,dbType)
|
||||
local kind=NormalizeLogType(logType)
|
||||
if kind=="creature" then return dbType=="monster" end
|
||||
if kind=="gameObject" then return dbType=="object" end
|
||||
if kind=="item" then return dbType=="item" end
|
||||
return false
|
||||
end
|
||||
|
||||
local function EntryFromOrderedObjective(q,row)
|
||||
local ordered=q.objectiveData
|
||||
-- row.dataIndex is the objectiveData index bound by name in QuestLog's
|
||||
-- LocalizeObjectiveRows. row.index is only the server leaderboard position,
|
||||
-- which does not necessarily line up with pfQuest's objectiveData ordering,
|
||||
-- so prefer the name-resolved index and fall back to position only when the
|
||||
-- row was never localized (e.g. database not ready at snapshot time).
|
||||
local index=tonumber(row.dataIndex) or tonumber(row.index)
|
||||
if not ordered or not index then return nil end
|
||||
local entry=ordered[index]
|
||||
if not entry then return nil end
|
||||
if ObjectiveTypesMatch(row.type,entry.type) then return entry end
|
||||
O.stats.typeMismatch=O.stats.typeMismatch+1
|
||||
return nil
|
||||
end
|
||||
|
||||
local function CandidateName(kind,id)
|
||||
if kind=="creature" then return QuestieOcto.DatabaseAPI:GetCreatureName(id) end
|
||||
if kind=="gameObject" then return QuestieOcto.DatabaseAPI:GetObjectName(id) end
|
||||
if kind=="item" then return QuestieOcto.DatabaseAPI:GetItemName(id) end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function CandidateIDs(q,kind)
|
||||
if kind=="creature" then return q.objectives.creature or {} end
|
||||
if kind=="gameObject" then return q.objectives.gameObject or {} end
|
||||
if kind=="item" then return q.objectives.item or {} end
|
||||
return {}
|
||||
end
|
||||
|
||||
local function BestSameTypeCandidate(q,kind,row,used)
|
||||
local ids=CandidateIDs(q,kind)
|
||||
local count=0
|
||||
local onlyID=nil
|
||||
for _,id in pairs(ids) do
|
||||
if not used[kind..":"..tostring(id)] then count=count+1; onlyID=id end
|
||||
end
|
||||
if count==1 then O.stats.single=O.stats.single+1; return onlyID end
|
||||
|
||||
local bestID=nil
|
||||
local bestDistance=999999
|
||||
local desc=row.text or ""
|
||||
for _,id in pairs(ids) do
|
||||
if not used[kind..":"..tostring(id)] then
|
||||
local name=CandidateName(kind,id)
|
||||
if name then
|
||||
local distance=Levenshtein(desc,name)
|
||||
if distance<bestDistance then bestDistance=distance; bestID=id end
|
||||
end
|
||||
end
|
||||
end
|
||||
if bestID then O.stats.fuzzy=O.stats.fuzzy+1 end
|
||||
return bestID
|
||||
end
|
||||
|
||||
function O:ResolveQuest(questID)
|
||||
if not DependenciesReady() then return nil end
|
||||
local q=QuestieOcto.QuestModel:Get(questID)
|
||||
if not q then return nil end
|
||||
|
||||
local state=QuestieOcto.QuestLog.active[questID]
|
||||
local result={questID=questID,creature={},gameObject={},item={}}
|
||||
|
||||
-- Failed quests retain their tracker state but have no active objective map
|
||||
-- guidance until the server permits a retry.
|
||||
if state and state.failed then return result end
|
||||
|
||||
local used={}
|
||||
local irTargets,skipCreature,skipObject=BuildIRTargets(q)
|
||||
|
||||
for _,row in pairs(state and state.objectives or {}) do
|
||||
local ordered=EntryFromOrderedObjective(q,row)
|
||||
local kind=nil
|
||||
local id=nil
|
||||
|
||||
if ordered then
|
||||
kind=ordered.kind
|
||||
id=ordered.id
|
||||
O.stats.direct=O.stats.direct+1
|
||||
else
|
||||
kind=NormalizeLogType(row.type)
|
||||
id=BestSameTypeCandidate(q,kind,row,used)
|
||||
if id then O.stats.fallback=O.stats.fallback+1 end
|
||||
end
|
||||
|
||||
if id and kind then
|
||||
local key=kind..":"..tostring(id)
|
||||
if not used[key] then
|
||||
used[key]=true
|
||||
O.stats.mapped=O.stats.mapped+1
|
||||
|
||||
if kind=="creature" and not skipCreature[id] then
|
||||
table.insert(result.creature,MergeState({kind="creature",id=id},row))
|
||||
O.stats.creature=O.stats.creature+1
|
||||
elseif kind=="gameObject" and not skipObject[id] then
|
||||
table.insert(result.gameObject,MergeState({kind="gameObject",id=id},row))
|
||||
O.stats.object=O.stats.object+1
|
||||
elseif kind=="item" then
|
||||
table.insert(result.item,MergeState({
|
||||
itemID=id,name=QuestieOcto.DatabaseAPI:GetItemName(id),sources=ResolveItemSources(id)
|
||||
},row))
|
||||
O.stats.item=O.stats.item+1
|
||||
end
|
||||
else
|
||||
O.stats.unmapped=O.stats.unmapped+1
|
||||
end
|
||||
else
|
||||
O.stats.unmapped=O.stats.unmapped+1
|
||||
end
|
||||
end
|
||||
|
||||
-- Preserve DB-only/special candidates without inventing progress state.
|
||||
for i=1,table.getn(q.objectiveData or {}) do
|
||||
local entry=q.objectiveData[i]
|
||||
local key=entry.kind..":"..tostring(entry.id)
|
||||
if not used[key] then
|
||||
if entry.kind=="creature" and not skipCreature[entry.id] then
|
||||
table.insert(result.creature,{kind="creature",id=entry.id})
|
||||
O.stats.creature=O.stats.creature+1
|
||||
elseif entry.kind=="gameObject" and not skipObject[entry.id] then
|
||||
table.insert(result.gameObject,{kind="gameObject",id=entry.id})
|
||||
O.stats.object=O.stats.object+1
|
||||
elseif entry.kind=="item" then
|
||||
table.insert(result.item,{
|
||||
itemID=entry.id,name=QuestieOcto.DatabaseAPI:GetItemName(entry.id),sources=ResolveItemSources(entry.id)
|
||||
})
|
||||
O.stats.item=O.stats.item+1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- IR target guidance appears only while the required quest item is in the
|
||||
-- player's bags/equipment. Its corresponding ordinary U/O objective is
|
||||
-- suppressed even while the item is missing, matching pfQuest SearchQuestID.
|
||||
for _,target in pairs(irTargets) do
|
||||
if target.kind=="creature" then
|
||||
table.insert(result.creature,target)
|
||||
O.stats.creature=O.stats.creature+1
|
||||
else
|
||||
table.insert(result.gameObject,target)
|
||||
O.stats.object=O.stats.object+1
|
||||
end
|
||||
O.stats.irTargets=O.stats.irTargets+1
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
function O:Rebuild()
|
||||
if not DependenciesReady() then
|
||||
self.ready=false
|
||||
self.pending=true
|
||||
self.stats.dependencyWaits=(self.stats.dependencyWaits or 0)+1
|
||||
return
|
||||
end
|
||||
if self.running then self.pending=true; return end
|
||||
|
||||
self.pending=false
|
||||
self.stats.rebuilds=(self.stats.rebuilds or 0)+1
|
||||
self.generation=self.generation+1
|
||||
local generation=self.generation
|
||||
self.byQuest={}
|
||||
self.ready=false
|
||||
self.running=true
|
||||
ResetStats()
|
||||
|
||||
local ids={}
|
||||
for questID in pairs(QuestieOcto.QuestLog.active or {}) do table.insert(ids,questID) end
|
||||
table.sort(ids)
|
||||
|
||||
local pos=1
|
||||
local function step()
|
||||
if generation~=O.generation then return end
|
||||
local count=0
|
||||
while pos<=table.getn(ids) and count<32 do
|
||||
local questID=ids[pos]
|
||||
pos=pos+1
|
||||
local resolved=O:ResolveQuest(questID)
|
||||
if resolved then O.byQuest[questID]=resolved end
|
||||
O.stats.quests=O.stats.quests+1
|
||||
count=count+1
|
||||
end
|
||||
if pos<=table.getn(ids) then
|
||||
QuestieOcto.Scheduler:Enqueue(step,"objective-resolve")
|
||||
return
|
||||
end
|
||||
O.running=false
|
||||
O.ready=true
|
||||
RefreshIRPresence()
|
||||
QuestieOcto:SendMessage("OBJECTIVES_READY")
|
||||
if O.pending then O.pending=false; O:Schedule(0.01) end
|
||||
end
|
||||
QuestieOcto.Scheduler:Enqueue(step,"objective-resolve")
|
||||
end
|
||||
|
||||
function O:Schedule(delay)
|
||||
self.pending=true
|
||||
QuestieOcto.Scheduler:After(delay or 0.15,function()
|
||||
if O.pending then O.pending=false; O:Rebuild() end
|
||||
end,"objectives-rebuild")
|
||||
end
|
||||
|
||||
function O:RefreshQuests(changedQuests)
|
||||
if not self.ready or self.running or not DependenciesReady() then
|
||||
self:Schedule(0.01)
|
||||
return
|
||||
end
|
||||
|
||||
local ids={}
|
||||
for questID in pairs(changedQuests or {}) do table.insert(ids,tonumber(questID)) end
|
||||
table.sort(ids)
|
||||
if table.getn(ids)==0 then return end
|
||||
|
||||
self.running=true
|
||||
self.generation=self.generation+1
|
||||
local generation=self.generation
|
||||
local pos=1
|
||||
|
||||
local function step()
|
||||
if generation~=O.generation then return end
|
||||
local count=0
|
||||
while pos<=table.getn(ids) and count<8 do
|
||||
local questID=ids[pos]
|
||||
pos=pos+1
|
||||
if QuestieOcto.QuestLog.active[questID] then
|
||||
O.byQuest[questID]=O:ResolveQuest(questID)
|
||||
else
|
||||
O.byQuest[questID]=nil
|
||||
end
|
||||
count=count+1
|
||||
end
|
||||
|
||||
if pos<=table.getn(ids) then
|
||||
QuestieOcto.Scheduler:Enqueue(step,"objective-refresh-quests")
|
||||
return
|
||||
end
|
||||
|
||||
O.running=false
|
||||
RefreshIRPresence()
|
||||
QuestieOcto:SendMessage("OBJECTIVES_CHANGED",changedQuests)
|
||||
if O.pending then O.pending=false; O:Schedule(0.01) end
|
||||
end
|
||||
|
||||
QuestieOcto.Scheduler:Enqueue(step,"objective-refresh-quests")
|
||||
end
|
||||
|
||||
function O:OnDependencyChanged(changedQuests)
|
||||
if type(changedQuests)=="table" and self.ready then
|
||||
self:RefreshQuests(changedQuests)
|
||||
else
|
||||
self:Schedule(0.01)
|
||||
end
|
||||
end
|
||||
|
||||
QuestieOcto:RegisterMessage("QUEST_MAP_STATE_CHANGED",O,"OnDependencyChanged")
|
||||
QuestieOcto:RegisterMessage("DATABASE_API_READY",O,"OnDependencyChanged")
|
||||
|
||||
local bagFrame=CreateFrame("Frame","QuestieOctoObjectiveItemEvents",UIParent)
|
||||
bagFrame:RegisterEvent("BAG_UPDATE")
|
||||
bagFrame:RegisterEvent("UNIT_INVENTORY_CHANGED")
|
||||
bagFrame:SetScript("OnEvent",function()
|
||||
if event=="UNIT_INVENTORY_CHANGED" and arg1 and arg1~="player" then return end
|
||||
|
||||
-- Coalesce the entire loot/equipment burst, then touch the objective pipeline
|
||||
-- only if an IR quest-item dependency actually appeared/disappeared.
|
||||
QuestieOcto.Scheduler:After(0.25,function()
|
||||
if IRPresenceChanged() then
|
||||
local affected={}
|
||||
for questID in pairs(QuestieOcto.QuestLog.active or {}) do
|
||||
local q=QuestieOcto.QuestModel:Get(questID)
|
||||
if q and q.objectives and q.objectives.irItems and table.getn(q.objectives.irItems)>0 then
|
||||
affected[questID]=true
|
||||
end
|
||||
end
|
||||
if next(affected) then O:RefreshQuests(affected) end
|
||||
end
|
||||
end,"objective-ir-bag-check")
|
||||
end)
|
||||
Reference in New Issue
Block a user