Files
MetaHunt/api/core-feed-tracking.lua
DuvelCorp 66ff1ae711 Animations: wire remaining effects + 2-column options panel
- Shared FX toolkit in fx-celebrate.lua: mth_shake tween type, MTH_FX_Flash
  glow helper, MTH_CelebrateRunaway red shaking banner, gold title reset.
- pet.runaway, pet.happiness, pet.loyaltyup, pet.feed
- combat.ammoswap, combat.rangedswap, combat.mmprocs
- ui.zbar, ui.stable, ui.minimapping, ui.optionsclose
- book.model, book.search
- Options > Animations panel now uses a balanced two-column layout.
- CHANGELOG Animations entry expanded (no version bump).
2026-09-10 16:38:14 +02:00

1715 lines
51 KiB
Lua

local MTH_FEED_SCHEMA_VERSION = 3
local MTH_FEED_RECENT_LIMIT = 40
MTH_FEED_API_VERSION = 1
local MTH_FEED_Runtime = {
nextAttemptId = 1,
attempts = {},
sessionFeedsByPetId = {},
}
local MTH_FEED_CORE_HOOK_BOUNDARY_KEY = "core-feed-tracking-hooks"
MTH_FEED_Original_DropItemOnUnit = MTH_FEED_Original_DropItemOnUnit or nil
local MTH_FEED_TrackerFrame = nil
local MTH_FEED_ActiveCoreAttemptId = nil
local MTH_FEED_BootstrapFrame = nil
local MTH_FEED_NameToItemCache = {}
local MTH_FEED_InvalidateDietMapCache
local MTH_FEED_HARDCODED_LEVEL_RULES = {
{ minPetLevel = 1, minFoodLevel = 1 },
{ minPetLevel = 20, minFoodLevel = 5 },
{ minPetLevel = 31, minFoodLevel = 15 },
{ minPetLevel = 40, minFoodLevel = 25 },
{ minPetLevel = 50, minFoodLevel = 35 },
{ minPetLevel = 60, minFoodLevel = 45 },
}
MTH_FEED_TRACE = false
local function MTH_FEED_Trace(message)
if not MTH_FEED_TRACE then return end
if type(DEFAULT_CHAT_FRAME) == "table" and DEFAULT_CHAT_FRAME.AddMessage then
DEFAULT_CHAT_FRAME:AddMessage("|cff88aaff[MTH-FeedTrack]|r " .. tostring(message))
end
end
local function MTH_FEED_Now()
return time()
end
local function MTH_FEED_Trim(text)
local value = tostring(text or "")
value = string.gsub(value, "^%s+", "")
value = string.gsub(value, "%s+$", "")
return value
end
local function MTH_FEED_SafeLower(text)
return string.lower(MTH_FEED_Trim(text))
end
local function MTH_FEED_NormalizeItemName(text)
local value = MTH_FEED_SafeLower(text)
value = string.gsub(value, "[%[%]]", "")
value = string.gsub(value, "^%s+", "")
value = string.gsub(value, "%s+$", "")
value = string.gsub(value, "%s+", " ")
return value
end
local function MTH_FEED_IsHyperlinkText(linkText)
if type(linkText) ~= "string" then
return false
end
if string.find(linkText, "|Hitem:", 1, true) then
return true
end
if string.find(linkText, "^item:%d+") then
return true
end
return false
end
local function MTH_FEED_NormalizeItemLink(itemId, itemName, itemLink)
local raw = MTH_FEED_Trim(itemLink)
if raw ~= "" and MTH_FEED_IsHyperlinkText(raw) then
return raw
end
local numericId = tonumber(itemId)
if numericId then
if type(MTH_GetClickableItemLink) == "function" then
local clickable = MTH_GetClickableItemLink(numericId, itemName, false)
if clickable and clickable ~= "" and MTH_FEED_IsHyperlinkText(tostring(clickable)) then
return tostring(clickable)
end
end
return "item:" .. tostring(numericId) .. ":0:0:0"
end
return nil
end
local function MTH_FEED_MigrateItemLinkShapes(store)
if type(store) ~= "table" then
return
end
if type(store.byPetId) == "table" then
for _, petRow in pairs(store.byPetId) do
if type(petRow) == "table" and type(petRow.byItemId) == "table" then
for itemKey, itemRow in pairs(petRow.byItemId) do
if type(itemRow) == "table" then
local normalized = MTH_FEED_NormalizeItemLink(itemKey, itemRow.itemName, itemRow.itemLink)
itemRow.itemLink = normalized
end
end
end
end
end
if type(store.unknownFoods) == "table" and type(store.unknownFoods.byItemId) == "table" then
for itemKey, unknownRow in pairs(store.unknownFoods.byItemId) do
if type(unknownRow) == "table" then
local normalized = MTH_FEED_NormalizeItemLink(itemKey, unknownRow.itemName, unknownRow.itemLink)
unknownRow.itemLink = normalized
end
end
end
end
local function MTH_FEED_IndexBagFoods()
if type(GetContainerNumSlots) ~= "function" or type(GetContainerItemLink) ~= "function" then
return false
end
local cache = {}
for bag = 0, 4 do
local slots = tonumber(GetContainerNumSlots(bag)) or 0
for slot = 1, slots do
local link = GetContainerItemLink(bag, slot)
if link then
local itemId = nil
local _, _, idText = string.find(tostring(link), "item:(%d+)")
if idText then
itemId = tonumber(idText)
end
local linkName = nil
local _, _, parsedName = string.find(tostring(link), "|h%[([^%]]+)%]|h")
if parsedName and parsedName ~= "" then
linkName = parsedName
end
local infoName = nil
if itemId and type(GetItemInfo) == "function" then
infoName = GetItemInfo(itemId)
end
local resolvedName = infoName or linkName
local key = MTH_FEED_NormalizeItemName(resolvedName)
if key ~= "" then
link = MTH_FEED_NormalizeItemLink(itemId, resolvedName, link)
cache[key] = {
itemId = itemId,
itemLink = link,
itemName = resolvedName,
seenAt = MTH_FEED_Now(),
}
end
end
end
end
MTH_FEED_NameToItemCache = cache
return true
end
function MTH_FEED_HasPetFeedBuff()
if type(UnitBuff) ~= "function" then
return false
end
local index = 1
local buff = UnitBuff("pet", index)
while buff do
if string.find(tostring(buff), "Ability_Hunter_BeastTraining", 1, true) then
return true
end
index = index + 1
buff = UnitBuff("pet", index)
end
return false
end
local function MTH_FEED_GetCursorItemSnapshot()
if type(GetCursorInfo) ~= "function" then
return nil, nil, nil, nil
end
local infoType, idOrLink, maybeLink = GetCursorInfo()
if tostring(infoType or "") ~= "item" then
return nil, nil, nil, nil
end
local itemId = tonumber(idOrLink)
local itemLink = nil
if type(maybeLink) == "string" and maybeLink ~= "" then
itemLink = maybeLink
elseif type(idOrLink) == "string" and string.find(idOrLink, "|Hitem:", 1, true) then
itemLink = idOrLink
end
local itemName = nil
local itemIcon = nil
if itemId and type(GetItemInfo) == "function" then
itemName, _, _, _, _, _, _, _, _, itemIcon = GetItemInfo(itemId)
end
if (not itemIcon or itemIcon == "") and itemId and type(GetItemIcon) == "function" then
itemIcon = GetItemIcon(itemId)
end
itemLink = MTH_FEED_NormalizeItemLink(itemId, itemName, itemLink)
return itemId, itemName, itemLink, itemIcon
end
local function MTH_FEED_FindItemByNameInBags(foodName)
local target = MTH_FEED_NormalizeItemName(foodName)
if target == "" then
return nil, nil
end
if type(GetContainerNumSlots) ~= "function" or type(GetContainerItemLink) ~= "function" then
return nil, nil
end
for bag = 0, 4 do
local slots = tonumber(GetContainerNumSlots(bag)) or 0
for slot = 1, slots do
local link = GetContainerItemLink(bag, slot)
if link then
local linkName = nil
local _, _, parsedName = string.find(tostring(link), "|h%[([^%]]+)%]|h")
if parsedName and parsedName ~= "" then
linkName = parsedName
end
local itemId = nil
local _, _, idText = string.find(tostring(link), "item:(%d+)")
if idText then
itemId = tonumber(idText)
end
local itemName = nil
if itemId and type(GetItemInfo) == "function" then
itemName = GetItemInfo(itemId)
if itemName and itemName ~= "" then
itemName = tostring(itemName)
end
end
local resolvedName = itemName or linkName
if resolvedName and MTH_FEED_NormalizeItemName(resolvedName) == target then
return itemId, link
end
end
end
end
local cached = MTH_FEED_NameToItemCache and MTH_FEED_NameToItemCache[target] or nil
if type(cached) == "table" then
return cached.itemId, cached.itemLink
end
return nil, nil
end
local function MTH_FEED_FindItemIconInBags(itemId, itemName)
if type(GetContainerNumSlots) ~= "function" or type(GetContainerItemLink) ~= "function" or type(GetContainerItemInfo) ~= "function" then
return nil
end
local targetId = tonumber(itemId)
local targetName = MTH_FEED_NormalizeItemName(itemName)
for bag = 0, 4 do
local slots = tonumber(GetContainerNumSlots(bag)) or 0
for slot = 1, slots do
local link = GetContainerItemLink(bag, slot)
if link then
local matched = false
if targetId then
local _, _, idText = string.find(tostring(link), "item:(%d+)")
if tonumber(idText) == targetId then
matched = true
end
end
if not matched and targetName ~= "" then
local _, _, parsedName = string.find(tostring(link), "|h%[([^%]]+)%]|h")
if parsedName and MTH_FEED_NormalizeItemName(parsedName) == targetName then
matched = true
end
end
if matched then
local texture = GetContainerItemInfo(bag, slot)
if texture and texture ~= "" then
return texture
end
end
end
end
end
return nil
end
local function MTH_FEED_GetAttemptElapsed(attemptId)
local row = MTH_FEED_Runtime.attempts[attemptId]
if type(row) ~= "table" then
return nil
end
local now = (type(GetTime) == "function") and (GetTime() or 0) or 0
local startedAt = tonumber(row.startedAt) or 0
return now - startedAt
end
local function MTH_FEED_ClearActiveCoreAttempt(attemptId)
if tostring(MTH_FEED_ActiveCoreAttemptId or "") == tostring(attemptId or "") then
MTH_FEED_ActiveCoreAttemptId = nil
end
end
local function MTH_FEED_HandleCoreReject(reason, rawMessage)
local attemptId = MTH_FEED_ActiveCoreAttemptId
if attemptId == nil then
return
end
MTH_FEED_RecordRejectEvent(attemptId, {
reason = reason,
rawMessage = rawMessage,
})
MTH_FEED_FinalizeAttempt(attemptId, {
outcome = "rejected",
reason = reason,
})
MTH_FEED_ClearActiveCoreAttempt(attemptId)
end
local function MTH_FEED_HandleCoreAccept()
local attemptId = MTH_FEED_ActiveCoreAttemptId
if attemptId == nil then
return
end
local elapsed = MTH_FEED_GetAttemptElapsed(attemptId)
MTH_FEED_RecordBuffOutcome(attemptId, {
hasFeedBuff = true,
elapsed = elapsed,
})
MTH_FEED_FinalizeAttempt(attemptId, {
outcome = "accepted",
reason = "accepted",
})
MTH_FEED_ClearActiveCoreAttempt(attemptId)
end
local function MTH_FEED_EnsureActiveAttemptFromCursor(sourceTag)
if MTH_FEED_ActiveCoreAttemptId then
return MTH_FEED_ActiveCoreAttemptId
end
if not (CursorHasItem and CursorHasItem()) then
return nil
end
local itemId, itemName, itemLink, itemIcon = MTH_FEED_GetCursorItemSnapshot()
local pet = MTH_FEED_GetCurrentPetContext()
if type(pet) ~= "table" then
return nil
end
local attemptId = MTH_FEED_BeginAttempt({
itemId = itemId,
itemName = itemName,
itemLink = itemLink,
itemIcon = itemIcon,
source = sourceTag or "core:ui-error",
petId = pet.petId,
petName = pet.petName,
family = pet.family,
petLevel = pet.level,
foodLevel = itemId and MTH_FEED_GetFoodLevel(itemId) or nil,
})
if attemptId then
MTH_FEED_ActiveCoreAttemptId = attemptId
MTH_FEED_RecordClientDropResult(attemptId, { consumedFromCursor = false })
MTH_FEED_Trace("Core tracker bootstrapped attempt from cursor id=" .. tostring(attemptId)
.. " itemId=" .. tostring(itemId)
.. " source=" .. tostring(sourceTag or "core:ui-error"))
end
return attemptId
end
local function MTH_FEED_CoreTrackerOnEvent()
local evt = event
if evt == "UI_ERROR_MESSAGE" then
local msg = tostring(arg1 or "")
local lower = MTH_FEED_SafeLower(msg)
local isLowLevel = (SPELL_FAILED_FOOD_LOWLEVEL and string.find(msg, SPELL_FAILED_FOOD_LOWLEVEL))
or string.find(lower, "low level", 1, true)
or string.find(lower, "too low level", 1, true)
or string.find(lower, "level too low", 1, true)
local isWrongFood = (SPELL_FAILED_WRONG_PET_FOOD and string.find(msg, SPELL_FAILED_WRONG_PET_FOOD))
or string.find(lower, "wrong pet food", 1, true)
or string.find(lower, "doesn't like", 1, true)
or string.find(lower, "does not like", 1, true)
if isLowLevel then
MTH_FEED_EnsureActiveAttemptFromCursor("core:ui-error-low-level")
MTH_FEED_Trace("Core tracker UI_ERROR_MESSAGE => low-level")
MTH_FEED_HandleCoreReject("low-level", msg)
elseif isWrongFood then
MTH_FEED_EnsureActiveAttemptFromCursor("core:ui-error-wrong-food")
MTH_FEED_Trace("Core tracker UI_ERROR_MESSAGE => wrong-food")
MTH_FEED_HandleCoreReject("wrong-food", msg)
end
elseif evt == "CHAT_MSG_SPELL_TRADESKILLS" then
local line = tostring(arg1 or "")
local feedPattern = nil
if FEEDPET_LOG_FIRSTPERSON then
feedPattern = GFWUtils and GFWUtils.FormatToPattern and GFWUtils.FormatToPattern(FEEDPET_LOG_FIRSTPERSON) or nil
end
if feedPattern then
local _, _, foodName = string.find(line, feedPattern)
if foodName and foodName ~= "" then
local nowTs = (type(GetTime) == "function") and (GetTime() or 0) or 0
local suppressUntil = tonumber((_G and _G["MTH_FEED_SuppressChatUntil"]) or 0) or 0
local suppressFood = tostring((_G and _G["MTH_FEED_SuppressChatFoodName"]) or "")
if suppressUntil > 0 and nowTs > suppressUntil then
if _G then
_G["MTH_FEED_SuppressChatUntil"] = nil
_G["MTH_FEED_SuppressChatFoodName"] = nil
end
suppressUntil = 0
suppressFood = ""
end
if suppressUntil > 0 and nowTs <= suppressUntil then
local normalizedSuppress = MTH_FEED_NormalizeItemName(suppressFood)
local normalizedFood = MTH_FEED_NormalizeItemName(foodName)
if normalizedSuppress == "" or normalizedSuppress == normalizedFood then
MTH_FEED_Trace("Core tracker chat suppressed for food='" .. tostring(foodName) .. "' (owned by FeedOMatic)")
return
end
end
local itemId, itemLink = MTH_FEED_FindItemByNameInBags(foodName)
local pet = MTH_FEED_GetCurrentPetContext()
if pet and (itemId or foodName) then
local attemptId = MTH_FEED_BeginAttempt({
itemId = itemId,
itemName = foodName,
itemLink = itemLink,
source = "core:manual-chat",
petId = pet.petId,
petName = pet.petName,
family = pet.family,
petLevel = pet.level,
foodLevel = MTH_FEED_GetFoodLevel(itemId),
})
if attemptId then
MTH_FEED_ActiveCoreAttemptId = attemptId
MTH_FEED_Trace("Core tracker chat started attempt id=" .. tostring(attemptId)
.. " food='" .. tostring(foodName) .. "' itemId=" .. tostring(itemId))
end
else
MTH_FEED_Trace("Core tracker chat feed detected but item lookup failed for '" .. tostring(foodName) .. "'")
end
end
end
elseif evt == "BAG_UPDATE" then
MTH_FEED_IndexBagFoods()
MTH_FEED_InvalidateDietMapCache()
elseif evt == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS" then
if MTH_FEED_ActiveCoreAttemptId and MTH_FEED_HasPetFeedBuff() then
MTH_FEED_Trace("Core tracker buff event confirms acceptance")
MTH_FEED_HandleCoreAccept()
end
end
end
local MTH_FEED_CoreTrackerUpdateElapsed = 0
local MTH_FEED_LastStaleCleanupAt = 0
local function MTH_FEED_CoreTrackerOnUpdate()
MTH_FEED_CoreTrackerUpdateElapsed = MTH_FEED_CoreTrackerUpdateElapsed + (arg1 or 0)
if MTH_FEED_CoreTrackerUpdateElapsed < 0.10 then
return
end
MTH_FEED_CoreTrackerUpdateElapsed = 0
-- Periodic stale attempt cleanup (every ~30 seconds)
local now = GetTime() or 0
if (now - MTH_FEED_LastStaleCleanupAt) >= 30 then
MTH_FEED_LastStaleCleanupAt = now
if MTH_FEED_Runtime and type(MTH_FEED_Runtime.attempts) == "table" then
for id, row in pairs(MTH_FEED_Runtime.attempts) do
if type(row) == "table" then
local age = now - (tonumber(row.startedAt) or now)
if age > 10 then
MTH_FEED_Runtime.attempts[id] = nil
end
end
end
end
end
local attemptId = MTH_FEED_ActiveCoreAttemptId
if attemptId == nil then
return
end
local elapsed = MTH_FEED_GetAttemptElapsed(attemptId)
if elapsed == nil then
MTH_FEED_ClearActiveCoreAttempt(attemptId)
return
end
if MTH_FEED_HasPetFeedBuff() then
MTH_FEED_Trace("Core tracker OnUpdate detects feed buff")
MTH_FEED_HandleCoreAccept()
return
end
if elapsed >= 2.50 then
MTH_FEED_Trace("Core tracker timeout => no-buff reject elapsed=" .. tostring(elapsed))
MTH_FEED_RecordBuffOutcome(attemptId, {
hasFeedBuff = false,
elapsed = elapsed,
})
MTH_FEED_HandleCoreReject("no-buff", nil)
end
end
local function MTH_FEED_EnsureTrackerFrame()
if MTH_FEED_TrackerFrame then
return MTH_FEED_TrackerFrame
end
local frame = CreateFrame("Frame", "MTH_FeedTracking")
if not frame then
return nil
end
frame:RegisterEvent("UI_ERROR_MESSAGE")
frame:RegisterEvent("BAG_UPDATE")
frame:RegisterEvent("CHAT_MSG_SPELL_TRADESKILLS")
frame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS")
frame:SetScript("OnEvent", MTH_FEED_CoreTrackerOnEvent)
frame:SetScript("OnUpdate", MTH_FEED_CoreTrackerOnUpdate)
MTH_FEED_TrackerFrame = frame
MTH_FEED_IndexBagFoods()
return frame
end
local function MTH_FEED_CoreDropItemOnUnitHook(unit)
local target = tostring(unit or "")
local skip = (_G and _G["MTH_FEED_SuppressCoreDropHook"]) and true or false
if skip then
if type(MTH_FEED_Original_DropItemOnUnit) == "function" then
return MTH_FEED_Original_DropItemOnUnit(unit)
end
MTH_FEED_Trace("Core hook bypass failed: original DropItemOnUnit missing")
return
end
if target == "pet" and CursorHasItem and CursorHasItem() then
MTH_FEED_EnsureTrackerFrame()
local itemId, itemName, itemLink, itemIcon = MTH_FEED_GetCursorItemSnapshot()
local pet = MTH_FEED_GetCurrentPetContext()
if not itemId then
MTH_FEED_Trace("Core hook pet drop detected but cursor item could not be resolved")
end
if pet and itemId then
local attemptId = MTH_FEED_BeginAttempt({
itemId = itemId,
itemName = itemName,
itemLink = itemLink,
itemIcon = itemIcon,
source = "core:manual-drop",
petId = pet.petId,
petName = pet.petName,
family = pet.family,
petLevel = pet.level,
foodLevel = MTH_FEED_GetFoodLevel(itemId),
})
MTH_FEED_ActiveCoreAttemptId = attemptId
MTH_FEED_Trace("Core hook started manual attempt id=" .. tostring(attemptId)
.. " itemId=" .. tostring(itemId)
.. " petId=" .. tostring(pet.petId))
end
end
if type(MTH_FEED_Original_DropItemOnUnit) == "function" then
MTH_FEED_Original_DropItemOnUnit(unit)
else
MTH_FEED_Trace("Core hook failed: original DropItemOnUnit missing")
return
end
local activeId = MTH_FEED_ActiveCoreAttemptId
if activeId then
local consumed = (CursorHasItem and CursorHasItem()) and false or true
MTH_FEED_RecordClientDropResult(activeId, { consumedFromCursor = consumed })
if not consumed then
MTH_FEED_Trace("Core hook immediate reject: cursor still has item")
MTH_FEED_HandleCoreReject("client-drop-fail", nil)
end
end
end
local function MTH_FEED_InstallCoreHooks()
if type(DropItemOnUnit) ~= "function" then
MTH_FEED_Trace("InstallCoreHooks skipped: DropItemOnUnit unavailable")
return false
end
if MTH_FEED_Original_DropItemOnUnit == nil or MTH_FEED_Original_DropItemOnUnit == MTH_FEED_CoreDropItemOnUnitHook then
MTH_FEED_Original_DropItemOnUnit = DropItemOnUnit
end
if DropItemOnUnit ~= MTH_FEED_CoreDropItemOnUnitHook then
DropItemOnUnit = MTH_FEED_CoreDropItemOnUnitHook
end
if MTH and MTH.CaptureHookBoundary then
MTH:CaptureHookBoundary(MTH_FEED_CORE_HOOK_BOUNDARY_KEY, {
{ globalName = "DropItemOnUnit", originalName = "MTH_FEED_Original_DropItemOnUnit" },
})
end
return true
end
local function MTH_FEED_IsCoreHookInstalled()
return type(DropItemOnUnit) == "function" and DropItemOnUnit == MTH_FEED_CoreDropItemOnUnitHook
end
function MTH_FEED_DebugStatus()
local owner = nil
if MTH and MTH.GetGlobalHookOwner then
owner = MTH:GetGlobalHookOwner("DropItemOnUnit")
end
MTH_FEED_Trace("Status hookInstalled=" .. tostring(MTH_FEED_IsCoreHookInstalled())
.. " owner=" .. tostring(owner)
.. " trackerFrame=" .. tostring(MTH_FEED_TrackerFrame ~= nil)
.. " activeAttemptId=" .. tostring(MTH_FEED_ActiveCoreAttemptId))
return true
end
function MTH_FEED_ReinstallCoreTracking()
local ok = MTH_FEED_InstallCoreHooks()
MTH_FEED_EnsureTrackerFrame()
return ok and true or false
end
local function MTH_FEED_EnsureBootstrapFrame()
if MTH_FEED_BootstrapFrame ~= nil then
return MTH_FEED_BootstrapFrame
end
local frame = CreateFrame("Frame", "MTH_FeedTrackingBoot")
if not frame then
return nil
end
frame:RegisterEvent("VARIABLES_LOADED")
frame:RegisterEvent("PLAYER_LOGIN")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:SetScript("OnEvent", function()
MTH_FEED_ReinstallCoreTracking()
this:UnregisterAllEvents()
this:SetScript("OnEvent", nil)
end)
MTH_FEED_BootstrapFrame = frame
return frame
end
local function MTH_FEED_EnsureStore()
if type(MTH_CharSavedVariables) ~= "table" then
MTH_CharSavedVariables = {}
end
if type(MTH_CharSavedVariables.feedTracking) ~= "table" then
MTH_CharSavedVariables.feedTracking = {}
end
local store = MTH_CharSavedVariables.feedTracking
if store.schemaVersion ~= MTH_FEED_SCHEMA_VERSION then
store.schemaVersion = MTH_FEED_SCHEMA_VERSION
end
if store.updatedAt == nil then
store.updatedAt = 0
end
if type(store.byPetId) ~= "table" then
store.byPetId = {}
end
if type(store.levelRule) ~= "table" then
store.levelRule = {}
end
if type(store.levelRule.observations) ~= "table" then
store.levelRule.observations = {}
end
if type(store.levelRule.derived) ~= "table" then
store.levelRule.derived = {}
end
if type(store.levelRule.derived.minAcceptedFoodLevelByPetLevel) ~= "table" then
store.levelRule.derived.minAcceptedFoodLevelByPetLevel = {}
end
if type(store.levelRule.derived.confidenceByPetLevel) ~= "table" then
store.levelRule.derived.confidenceByPetLevel = {}
end
if store.levelRule.derived.lastRecomputeAt == nil then
store.levelRule.derived.lastRecomputeAt = 0
end
if type(store.exceptions) ~= "table" then
store.exceptions = {}
end
if type(store.exceptions.byItemId) ~= "table" then
store.exceptions.byItemId = {}
end
if type(store.unknownFoods) ~= "table" then
store.unknownFoods = {}
end
if type(store.unknownFoods.byItemId) ~= "table" then
store.unknownFoods.byItemId = {}
end
if type(store.runtime) ~= "table" then
store.runtime = {}
end
if store.runtime.nextAttemptId == nil then
store.runtime.nextAttemptId = 1
end
MTH_FEED_MigrateItemLinkShapes(store)
return store
end
local function MTH_FEED_TouchStore(store)
store.updatedAt = MTH_FEED_Now()
end
local function MTH_FEED_GetRuntimeAttemptId(store)
local nextId = tonumber(store.runtime.nextAttemptId) or 1
store.runtime.nextAttemptId = nextId + 1
return "feed-attempt-" .. tostring(nextId)
end
local function MTH_FEED_EnsurePetRow(store, petContext)
local petId = tostring((petContext and petContext.petId) or "")
if petId == "" then
return nil
end
local row = store.byPetId[petId]
if type(row) ~= "table" then
row = {
petId = petId,
petName = petContext and petContext.petName or nil,
family = petContext and petContext.family or nil,
totals = {
attempts = 0,
accepted = 0,
rejected = 0,
rejectedNoBuff = 0,
rejectedLowLevel = 0,
rejectedWrongFood = 0,
},
byItemId = {},
recent = {},
}
store.byPetId[petId] = row
end
if type(row.totals) ~= "table" then
row.totals = {}
end
row.totals.attempts = tonumber(row.totals.attempts) or 0
row.totals.accepted = tonumber(row.totals.accepted) or 0
row.totals.rejected = tonumber(row.totals.rejected) or 0
row.totals.rejectedNoBuff = tonumber(row.totals.rejectedNoBuff) or 0
row.totals.rejectedLowLevel = tonumber(row.totals.rejectedLowLevel) or 0
row.totals.rejectedWrongFood = tonumber(row.totals.rejectedWrongFood) or 0
if type(row.byItemId) ~= "table" then
row.byItemId = {}
end
if type(row.recent) ~= "table" then
row.recent = {}
end
if petContext then
if petContext.petName and petContext.petName ~= "" then
row.petName = petContext.petName
end
if petContext.family and petContext.family ~= "" then
row.family = petContext.family
end
end
return row
end
local function MTH_FEED_EnsurePetItemRow(petRow, itemId)
local key = tonumber(itemId)
if key == nil then
key = itemId
end
if key == nil then
return nil
end
if type(petRow.byItemId[key]) ~= "table" then
petRow.byItemId[key] = {
attempts = 0,
accepted = 0,
rejected = 0,
lastOutcome = nil,
lastReason = nil,
itemName = nil,
itemLink = nil,
itemIcon = nil,
}
end
local row = petRow.byItemId[key]
row.attempts = tonumber(row.attempts) or 0
row.accepted = tonumber(row.accepted) or 0
row.rejected = tonumber(row.rejected) or 0
return row
end
local function MTH_FEED_RecordPetRecent(petRow, entry)
table.insert(petRow.recent, 1, entry)
while table.getn(petRow.recent) > MTH_FEED_RECENT_LIMIT do
table.remove(petRow.recent)
end
end
local function MTH_FEED_NormalizeReason(reason, rawMessage)
local normalized = MTH_FEED_SafeLower(reason)
if normalized == "accepted" then return "accepted" end
if normalized == "low-level" then return "low-level" end
if normalized == "wrong-food" then return "wrong-food" end
if normalized == "no-buff" then return "no-buff" end
if normalized == "client-drop-fail" then return "client-drop-fail" end
if normalized == "unknown" then return "unknown" end
local msg = MTH_FEED_SafeLower(rawMessage)
if msg ~= "" then
if (SPELL_FAILED_FOOD_LOWLEVEL and string.find(msg, MTH_FEED_SafeLower(SPELL_FAILED_FOOD_LOWLEVEL), 1, true))
or string.find(msg, "low level", 1, true)
or string.find(msg, "too low level", 1, true)
or string.find(msg, "level too low", 1, true) then
return "low-level"
end
if (SPELL_FAILED_WRONG_PET_FOOD and string.find(msg, MTH_FEED_SafeLower(SPELL_FAILED_WRONG_PET_FOOD), 1, true))
or string.find(msg, "wrong pet food", 1, true)
or string.find(msg, "doesn't like", 1, true)
or string.find(msg, "does not like", 1, true) then
return "wrong-food"
end
end
return "unknown"
end
local MTH_FEED_DietMapCache = nil
MTH_FEED_InvalidateDietMapCache = function()
MTH_FEED_DietMapCache = nil
end
local function MTH_FEED_GetDietByItemIdMap()
if MTH_FEED_DietMapCache then
return MTH_FEED_DietMapCache
end
local map = {}
if type(FOM_Foods) ~= "table" then
return map
end
for dietName, ids in pairs(FOM_Foods) do
if type(ids) == "table" then
for _, itemId in ids do
local numeric = tonumber(itemId)
if numeric then
map[numeric] = dietName
end
end
end
end
MTH_FEED_DietMapCache = map
return map
end
local function MTH_FEED_ResolveFoodLevel(itemId)
local numeric = tonumber(itemId)
if not numeric then
return nil
end
local store = MTH_FEED_EnsureStore()
local unknown = store.unknownFoods and store.unknownFoods.byItemId and store.unknownFoods.byItemId[numeric] or nil
if type(unknown) == "table" and type(unknown.resolved) == "table" and tonumber(unknown.resolved.foodLevel) then
return tonumber(unknown.resolved.foodLevel)
end
if type(GetItemInfo) == "function" then
local _, _, _, itemLevel, minLevel = GetItemInfo(numeric)
local resolved = tonumber(minLevel) or tonumber(itemLevel)
if resolved and resolved > 0 then
return resolved
end
end
return nil
end
function MTH_FEED_GetStore()
return MTH_FEED_EnsureStore()
end
function MTH_FEED_GetCurrentPetContext()
local info = (type(MTH_GetCurrentPetInfo) == "function") and MTH_GetCurrentPetInfo() or nil
if type(info) ~= "table" then
return nil
end
local petId = info.id
if petId == nil or tostring(petId) == "" then
return nil
end
return {
petId = tostring(petId),
petName = info.name,
family = info.family,
level = tonumber(info.level),
happiness = tonumber(info.happiness),
}
end
function MTH_FEED_GetFoodLevel(itemId)
return MTH_FEED_ResolveFoodLevel(itemId)
end
function MTH_FEED_GetFoodMeta(itemId)
local numeric = tonumber(itemId)
if not numeric then
return nil
end
local dietMap = MTH_FEED_GetDietByItemIdMap()
local known = dietMap[numeric] ~= nil
return {
itemId = numeric,
foodLevel = MTH_FEED_ResolveFoodLevel(numeric),
diet = dietMap[numeric],
known = known,
}
end
function MTH_FEED_IsKnownFoodItem(itemId)
local numeric = tonumber(itemId)
if not numeric then
return false
end
if type(FOM_IsKnownFood) == "function" then
local ok, value = pcall(FOM_IsKnownFood, numeric)
if ok and value then
return true
end
end
local dietMap = MTH_FEED_GetDietByItemIdMap()
return dietMap[numeric] ~= nil
end
function MTH_FEED_BeginAttempt(payload)
local store = MTH_FEED_EnsureStore()
local currentPet = MTH_FEED_GetCurrentPetContext()
local payloadPetId = payload and payload.petId or nil
if payloadPetId ~= nil and tostring(payloadPetId) == "" then
payloadPetId = nil
end
local petId = payloadPetId or (currentPet and currentPet.petId)
if petId == nil or tostring(petId) == "" then
return nil
end
local attemptId = MTH_FEED_GetRuntimeAttemptId(store)
local itemId = payload and tonumber(payload.itemId) or nil
local meta = itemId and MTH_FEED_GetFoodMeta(itemId) or nil
local itemIcon = payload and payload.itemIcon or nil
if (not itemIcon or itemIcon == "") and itemId and type(GetItemInfo) == "function" then
local _, _, _, _, _, _, _, _, _, infoTexture = GetItemInfo(itemId)
if infoTexture and infoTexture ~= "" then
itemIcon = infoTexture
end
end
MTH_FEED_Runtime.attempts[attemptId] = {
id = attemptId,
startedAt = GetTime and (GetTime() or 0) or 0,
startedAtTs = MTH_FEED_Now(),
petId = tostring(petId),
petName = payload and payload.petName or (currentPet and currentPet.petName) or nil,
family = payload and payload.family or (currentPet and currentPet.family) or nil,
petLevel = payload and tonumber(payload.petLevel) or (currentPet and tonumber(currentPet.level) or nil),
itemId = itemId,
itemName = payload and payload.itemName or nil,
itemLink = MTH_FEED_NormalizeItemLink(itemId, payload and payload.itemName or nil, payload and payload.itemLink or nil),
itemIcon = itemIcon,
foodLevel = (payload and tonumber(payload.foodLevel)) or (meta and meta.foodLevel) or nil,
source = payload and payload.source or "unknown",
consumedFromCursor = nil,
rejectReason = nil,
rejectRawMessage = nil,
hasFeedBuff = nil,
happinessTick = nil,
elapsed = nil,
finalized = false,
}
if itemId and not (meta and meta.known) then
MTH_FEED_RecordUnknownFoodCandidate({
itemId = itemId,
itemName = payload and payload.itemName or nil,
itemLink = MTH_FEED_NormalizeItemLink(itemId, payload and payload.itemName or nil, payload and payload.itemLink or nil),
source = "feed-attempt",
observedFoodLevel = meta and meta.foodLevel or nil,
observedDietHint = nil,
evidenceType = "feed-attempt",
})
end
MTH_FEED_Trace("Begin attempt id=" .. tostring(attemptId)
.. " petId=" .. tostring(petId)
.. " itemId=" .. tostring(itemId)
.. " foodLevel=" .. tostring((payload and tonumber(payload.foodLevel)) or (meta and meta.foodLevel) or nil)
.. " source=" .. tostring(payload and payload.source or "unknown"))
MTH_FEED_TouchStore(store)
return attemptId
end
function MTH_FEED_RecordClientDropResult(attemptId, payload)
local row = MTH_FEED_Runtime.attempts[attemptId]
if type(row) ~= "table" then
MTH_FEED_Trace("RecordClientDropResult missing attempt id=" .. tostring(attemptId))
return false
end
row.consumedFromCursor = (payload and payload.consumedFromCursor) and true or false
MTH_FEED_Trace("RecordClientDropResult id=" .. tostring(attemptId)
.. " consumedFromCursor=" .. tostring(row.consumedFromCursor and true or false))
return true
end
function MTH_FEED_RecordRejectEvent(attemptId, payload)
local row = MTH_FEED_Runtime.attempts[attemptId]
if type(row) ~= "table" then
MTH_FEED_Trace("RecordRejectEvent missing attempt id=" .. tostring(attemptId))
return false
end
local rawMessage = payload and payload.rawMessage or nil
row.rejectReason = MTH_FEED_NormalizeReason(payload and payload.reason or nil, rawMessage)
row.rejectRawMessage = rawMessage
MTH_FEED_Trace("RecordRejectEvent id=" .. tostring(attemptId)
.. " reason=" .. tostring(row.rejectReason)
.. " raw='" .. tostring(rawMessage or "") .. "'")
return true
end
function MTH_FEED_RecordBuffOutcome(attemptId, payload)
local row = MTH_FEED_Runtime.attempts[attemptId]
if type(row) ~= "table" then
MTH_FEED_Trace("RecordBuffOutcome missing attempt id=" .. tostring(attemptId))
return false
end
row.hasFeedBuff = (payload and payload.hasFeedBuff) and true or false
row.elapsed = payload and tonumber(payload.elapsed) or row.elapsed
MTH_FEED_Trace("RecordBuffOutcome id=" .. tostring(attemptId)
.. " hasFeedBuff=" .. tostring(row.hasFeedBuff and true or false)
.. " elapsed=" .. tostring(row.elapsed))
return true
end
function MTH_FEED_RecordHappinessTick(attemptId, payload)
local row = MTH_FEED_Runtime.attempts[attemptId]
if type(row) ~= "table" then
MTH_FEED_Trace("RecordHappinessTick missing attempt id=" .. tostring(attemptId))
return false
end
row.happinessTick = payload and tonumber(payload.happinessTick) or row.happinessTick
if payload and tonumber(payload.petLevel) then
row.petLevel = tonumber(payload.petLevel)
end
MTH_FEED_Trace("RecordHappinessTick id=" .. tostring(attemptId)
.. " happinessTick=" .. tostring(row.happinessTick)
.. " petLevel=" .. tostring(row.petLevel))
return true
end
function MTH_FEED_RecordLevelObservation(petLevel, foodLevel, outcome)
return true
end
function MTH_FEED_RecomputeDerivedLevelRule()
local store = MTH_FEED_EnsureStore()
store.levelRule.derived.lastRecomputeAt = MTH_FEED_Now()
MTH_FEED_TouchStore(store)
return true
end
local function MTH_FEED_GetHardcodedMinFoodLevel(petLevel)
local level = tonumber(petLevel)
if not level then
return nil
end
local minFood = nil
for i = 1, table.getn(MTH_FEED_HARDCODED_LEVEL_RULES) do
local row = MTH_FEED_HARDCODED_LEVEL_RULES[i]
if level >= (tonumber(row.minPetLevel) or 0) then
minFood = tonumber(row.minFoodLevel)
else
break
end
end
return minFood
end
function MTH_FEED_GetDerivedMinFoodLevel(petLevel)
return MTH_FEED_GetHardcodedMinFoodLevel(petLevel)
end
function MTH_FEED_GetFoodCompatibilityWindow(petLevel)
local numericPet = tonumber(petLevel)
if not numericPet then
return nil, nil
end
local minAccepted = MTH_FEED_GetHardcodedMinFoodLevel(numericPet)
if minAccepted == nil then
minAccepted = numericPet - 15
if minAccepted < 1 then
minAccepted = 1
end
end
local maxAccepted = numericPet
if maxAccepted < 1 then
maxAccepted = 1
end
return tonumber(minAccepted), tonumber(maxAccepted)
end
function MTH_FEED_IsFoodCompatible(petLevel, foodLevel)
local numericPet = tonumber(petLevel)
local numericFood = tonumber(foodLevel)
if not numericPet or not numericFood then
return false
end
local minAccepted, maxAccepted = MTH_FEED_GetFoodCompatibilityWindow(numericPet)
if minAccepted and numericFood < minAccepted then
return false
end
if maxAccepted and numericFood > maxAccepted then
return false
end
return true
end
function MTH_FEED_IsExceptionReject(itemId, petLevel)
local key = tonumber(itemId)
if not key then
return false
end
local store = MTH_FEED_EnsureStore()
local row = store.exceptions.byItemId[key]
if type(row) ~= "table" then
return false
end
local numericPet = tonumber(petLevel)
local blockedAtOrAbove = tonumber(row.blockAtOrAbovePetLevel)
if blockedAtOrAbove and numericPet and numericPet >= blockedAtOrAbove then
return true
end
if row.anomalyReject ~= true then
return false
end
local lastObservedAt = tonumber(row.lastObservedAt) or 0
if lastObservedAt > 0 then
local age = MTH_FEED_Now() - lastObservedAt
if age > 900 then
return false
end
end
local reasons = type(row.reasons) == "table" and row.reasons or nil
if not reasons then
return false
end
local lowLevelCount = tonumber(reasons.lowLevel) or 0
if lowLevelCount >= 1 then
local maxPetLevel = tonumber(row.maxPetLevel)
if numericPet and maxPetLevel then
if numericPet > maxPetLevel then
return true
end
else
return true
end
end
local wrongFoodCount = tonumber(reasons.wrongFood) or 0
if wrongFoodCount < 2 then
return false
end
return true
end
function MTH_FEED_BlockItemForPetLevel(itemId, petLevel, reason)
local key = tonumber(itemId)
local numericPet = tonumber(petLevel)
if not key or not numericPet then
return false
end
if numericPet < 1 then
numericPet = 1
end
local store = MTH_FEED_EnsureStore()
if type(store.exceptions.byItemId[key]) ~= "table" then
store.exceptions.byItemId[key] = {
anomalyReject = true,
reasons = { noBuff = 0, wrongFood = 0, lowLevel = 0, unknown = 0 },
lastObservedAt = 0,
}
end
local row = store.exceptions.byItemId[key]
if type(row.reasons) ~= "table" then
row.reasons = { noBuff = 0, wrongFood = 0, lowLevel = 0, unknown = 0 }
end
row.anomalyReject = true
row.lastObservedAt = MTH_FEED_Now()
if tonumber(row.blockAtOrAbovePetLevel) == nil or numericPet < tonumber(row.blockAtOrAbovePetLevel) then
row.blockAtOrAbovePetLevel = numericPet
end
local normalizedReason = MTH_FEED_NormalizeReason(reason, nil)
if normalizedReason == "no-buff" then
row.reasons.noBuff = (tonumber(row.reasons.noBuff) or 0) + 1
elseif normalizedReason == "low-level" then
row.reasons.lowLevel = (tonumber(row.reasons.lowLevel) or 0) + 1
elseif normalizedReason == "wrong-food" then
row.reasons.wrongFood = (tonumber(row.reasons.wrongFood) or 0) + 1
else
row.reasons.unknown = (tonumber(row.reasons.unknown) or 0) + 1
end
MTH_FEED_TouchStore(store)
MTH_FEED_Trace("BlockItemForPetLevel itemId=" .. tostring(key)
.. " blockAtOrAbove=" .. tostring(row.blockAtOrAbovePetLevel)
.. " reason=" .. tostring(normalizedReason))
return true
end
function MTH_FEED_GetPetFeedStats(petId)
local key = tostring(petId or "")
if key == "" then
return nil
end
local store = MTH_FEED_EnsureStore()
return store.byPetId[key]
end
local function MTH_FEED_UpsertException(store, itemId, reason, petLevel)
if not itemId then
return
end
local key = tonumber(itemId) or itemId
if type(store.exceptions.byItemId[key]) ~= "table" then
store.exceptions.byItemId[key] = {
anomalyReject = true,
reasons = { noBuff = 0, wrongFood = 0, lowLevel = 0, unknown = 0 },
lastObservedAt = 0,
}
end
local row = store.exceptions.byItemId[key]
if type(row.reasons) ~= "table" then
row.reasons = { noBuff = 0, wrongFood = 0, lowLevel = 0, unknown = 0 }
end
if reason == "no-buff" then
row.reasons.noBuff = (tonumber(row.reasons.noBuff) or 0) + 1
elseif reason == "wrong-food" then
row.reasons.wrongFood = (tonumber(row.reasons.wrongFood) or 0) + 1
elseif reason == "low-level" then
row.reasons.lowLevel = (tonumber(row.reasons.lowLevel) or 0) + 1
local numericPetLevel = tonumber(petLevel)
if numericPetLevel and numericPetLevel > 1 then
local derivedMaxPetLevel = numericPetLevel - 1
if derivedMaxPetLevel < 1 then
derivedMaxPetLevel = 1
end
if tonumber(row.maxPetLevel) == nil or derivedMaxPetLevel < tonumber(row.maxPetLevel) then
row.maxPetLevel = derivedMaxPetLevel
end
end
else
row.reasons.unknown = (tonumber(row.reasons.unknown) or 0) + 1
end
row.anomalyReject = true
row.lastObservedAt = MTH_FEED_Now()
end
local function MTH_FEED_ClearStaleExceptionOnAccept(store, itemId)
if not itemId or type(store) ~= "table" or type(store.exceptions) ~= "table" or type(store.exceptions.byItemId) ~= "table" then
return
end
local key = tonumber(itemId) or itemId
local row = store.exceptions.byItemId[key]
if type(row) ~= "table" then
return
end
if type(row.reasons) ~= "table" then
store.exceptions.byItemId[key] = nil
return
end
row.reasons.noBuff = 0
row.lastObservedAt = MTH_FEED_Now()
local noBuff = tonumber(row.reasons.noBuff) or 0
local lowLevel = tonumber(row.reasons.lowLevel) or 0
local wrongFood = tonumber(row.reasons.wrongFood) or 0
local unknown = tonumber(row.reasons.unknown) or 0
if noBuff <= 0
and lowLevel <= 0
and wrongFood <= 0
and unknown <= 0
and tonumber(row.blockAtOrAbovePetLevel) == nil
and tonumber(row.maxPetLevel) == nil then
store.exceptions.byItemId[key] = nil
end
end
function MTH_FEED_FinalizeAttempt(attemptId, payload)
local row = MTH_FEED_Runtime.attempts[attemptId]
if type(row) ~= "table" then
MTH_FEED_Trace("FinalizeAttempt missing attempt id=" .. tostring(attemptId))
return false
end
if row.finalized then
MTH_FEED_Trace("FinalizeAttempt already finalized id=" .. tostring(attemptId))
return true
end
local store = MTH_FEED_EnsureStore()
local outcome = MTH_FEED_SafeLower(payload and payload.outcome or "")
local reason = MTH_FEED_NormalizeReason(payload and payload.reason or row.rejectReason, row.rejectRawMessage)
if outcome ~= "accepted" and outcome ~= "rejected" then
if row.hasFeedBuff == true then
outcome = "accepted"
else
outcome = "rejected"
end
end
if outcome == "accepted" then
reason = "accepted"
elseif reason == "accepted" then
reason = "unknown"
end
local petContext = {
petId = row.petId,
petName = row.petName,
family = row.family,
}
if petContext.petId == nil or tostring(petContext.petId) == "" then
local currentPet = MTH_FEED_GetCurrentPetContext()
if type(currentPet) == "table" and currentPet.petId and tostring(currentPet.petId) ~= "" then
petContext.petId = tostring(currentPet.petId)
if row.petId == nil or tostring(row.petId) == "" then
row.petId = petContext.petId
end
end
end
local petRow = MTH_FEED_EnsurePetRow(store, petContext)
if type(petRow) ~= "table" then
MTH_FEED_Trace("FinalizeAttempt failed pet row id=" .. tostring(attemptId) .. " petId=" .. tostring(row.petId))
return false
end
local itemRow = MTH_FEED_EnsurePetItemRow(petRow, row.itemId)
petRow.totals.attempts = petRow.totals.attempts + 1
if itemRow then
itemRow.attempts = itemRow.attempts + 1
end
if outcome == "accepted" then
petRow.totals.accepted = petRow.totals.accepted + 1
if itemRow then
itemRow.accepted = itemRow.accepted + 1
end
MTH_FEED_ClearStaleExceptionOnAccept(store, row.itemId)
local petKey = tostring((petRow and petRow.petId) or row.petId or "")
if petKey ~= "" then
MTH_FEED_Runtime.sessionFeedsByPetId[petKey] = (tonumber(MTH_FEED_Runtime.sessionFeedsByPetId[petKey]) or 0) + 1
end
if type(MTH_FX_Flash) == "function" and type(MTH_FX_IsEnabled) == "function"
and MTH_FX_IsEnabled("pet.feed") then
local target = getglobal("zButtonPet")
if target then
MTH_FX_Flash(target, 0.35, 1.00, 0.35, 0.5)
end
end
else
petRow.totals.rejected = petRow.totals.rejected + 1
if reason == "no-buff" then
petRow.totals.rejectedNoBuff = petRow.totals.rejectedNoBuff + 1
elseif reason == "low-level" then
petRow.totals.rejectedLowLevel = petRow.totals.rejectedLowLevel + 1
elseif reason == "wrong-food" then
petRow.totals.rejectedWrongFood = petRow.totals.rejectedWrongFood + 1
end
if itemRow then
itemRow.rejected = itemRow.rejected + 1
end
MTH_FEED_UpsertException(store, row.itemId, reason, row.petLevel)
end
if itemRow then
itemRow.lastOutcome = outcome
itemRow.lastReason = reason
if row.itemName and row.itemName ~= "" then
itemRow.itemName = row.itemName
end
itemRow.itemLink = MTH_FEED_NormalizeItemLink(row.itemId, row.itemName, row.itemLink)
local persistedIcon = row.itemIcon
if (not persistedIcon or persistedIcon == "") and row.itemId and type(GetItemInfo) == "function" then
local _, _, _, _, _, _, _, _, _, infoTexture = GetItemInfo(row.itemId)
if infoTexture and infoTexture ~= "" then
persistedIcon = infoTexture
end
end
if (not persistedIcon or persistedIcon == "") and row.itemId and type(GetItemIcon) == "function" then
local directTexture = GetItemIcon(row.itemId)
if directTexture and directTexture ~= "" then
persistedIcon = directTexture
end
end
if persistedIcon and persistedIcon ~= "" then
itemRow.itemIcon = persistedIcon
end
end
MTH_FEED_RecordPetRecent(petRow, {
ts = MTH_FEED_Now(),
itemId = row.itemId,
itemName = row.itemName,
petLevel = row.petLevel,
foodLevel = row.foodLevel,
outcome = outcome,
reason = reason,
source = row.source,
})
row.finalized = true
MTH_FEED_Trace("FinalizeAttempt id=" .. tostring(attemptId)
.. " outcome=" .. tostring(outcome)
.. " reason=" .. tostring(reason)
.. " petId=" .. tostring(row.petId)
.. " itemId=" .. tostring(row.itemId)
.. " petAttempts=" .. tostring(petRow.totals.attempts)
.. " petAccepted=" .. tostring(petRow.totals.accepted)
.. " petRejected=" .. tostring(petRow.totals.rejected))
MTH_FEED_Runtime.attempts[attemptId] = nil
MTH_FEED_TouchStore(store)
return true
end
function MTH_FEED_GetSessionFeedCount(petId)
local petKey = tostring(petId or "")
if petKey == "" then
local pet = MTH_FEED_GetCurrentPetContext()
petKey = tostring((pet and pet.petId) or "")
end
if petKey == "" then
return 0
end
return tonumber(MTH_FEED_Runtime.sessionFeedsByPetId[petKey]) or 0
end
function MTH_FEED_DebugSessionFeeds()
local pet = MTH_FEED_GetCurrentPetContext()
local petId = pet and pet.petId or "(none)"
local count = MTH_FEED_GetSessionFeedCount(petId)
local hookOk = MTH_FEED_IsCoreHookInstalled()
local frameOk = MTH_FEED_TrackerFrame ~= nil
local activeId = tostring(MTH_FEED_ActiveCoreAttemptId or "nil")
if type(DEFAULT_CHAT_FRAME) == "table" and DEFAULT_CHAT_FRAME.AddMessage then
DEFAULT_CHAT_FRAME:AddMessage("|cff88aaff[MTH-FeedTrack]|r petId=" .. tostring(petId)
.. " feeds=" .. tostring(count)
.. " hookOk=" .. tostring(hookOk)
.. " frameOk=" .. tostring(frameOk)
.. " activeAttempt=" .. activeId)
local anyFeeds = false
for k, v in pairs(MTH_FEED_Runtime.sessionFeedsByPetId) do
anyFeeds = true
DEFAULT_CHAT_FRAME:AddMessage("|cff88aaff[MTH-FeedTrack]|r session petId='" .. tostring(k) .. "' count=" .. tostring(v))
end
if not anyFeeds then
DEFAULT_CHAT_FRAME:AddMessage("|cff88aaff[MTH-FeedTrack]|r (no session feeds recorded)")
end
end
end
function MTH_FEED_RecordUnknownFoodCandidate(payload)
local store = MTH_FEED_EnsureStore()
local itemId = payload and tonumber(payload.itemId) or nil
if not itemId then
MTH_FEED_Trace("RecordUnknownFoodCandidate ignored: missing itemId")
return false
end
local queue = store.unknownFoods.byItemId
if type(queue[itemId]) ~= "table" then
queue[itemId] = {
itemId = itemId,
itemName = payload and payload.itemName or nil,
itemLink = payload and payload.itemLink or nil,
firstSeenAt = MTH_FEED_Now(),
lastSeenAt = MTH_FEED_Now(),
seenCount = 0,
sources = {},
observedFoodLevel = nil,
observedDietHints = {},
state = "unknown",
confidence = 0,
resolved = {
foodLevel = nil,
familyMask = nil,
resolvedFrom = nil,
resolvedAt = nil,
},
}
end
local row = queue[itemId]
row.lastSeenAt = MTH_FEED_Now()
row.seenCount = (tonumber(row.seenCount) or 0) + 1
if payload and payload.itemName and payload.itemName ~= "" then
row.itemName = payload.itemName
end
if payload and payload.itemLink and payload.itemLink ~= "" then
row.itemLink = MTH_FEED_NormalizeItemLink(itemId, row.itemName, payload.itemLink)
end
local source = payload and tostring(payload.source or "") or ""
if source ~= "" then
local seen = false
for i = 1, table.getn(row.sources) do
if row.sources[i] == source then
seen = true
break
end
end
if not seen then
table.insert(row.sources, source)
end
end
if payload and payload.observedFoodLevel ~= nil then
row.observedFoodLevel = tonumber(payload.observedFoodLevel)
end
local dietHint = payload and payload.observedDietHint or nil
if dietHint ~= nil and tostring(dietHint) ~= "" then
local hint = tostring(dietHint)
local seen = false
for i = 1, table.getn(row.observedDietHints) do
if row.observedDietHints[i] == hint then
seen = true
break
end
end
if not seen then
table.insert(row.observedDietHints, hint)
end
end
if row.state == "unknown" then
row.state = "candidate-food"
end
if payload and payload.evidenceType == "runtime-outcome" and row.state == "candidate-food" then
row.confidence = math.min(1, (tonumber(row.confidence) or 0) + 0.2)
else
row.confidence = math.min(1, (tonumber(row.confidence) or 0) + 0.1)
end
MTH_FEED_Trace("RecordUnknownFoodCandidate itemId=" .. tostring(itemId)
.. " state=" .. tostring(row.state)
.. " confidence=" .. tostring(row.confidence)
.. " source=" .. tostring(payload and payload.source or ""))
MTH_FEED_TouchStore(store)
return true
end
function MTH_FEED_TryClassifyUnknownFood(itemId, evidence)
local store = MTH_FEED_EnsureStore()
local key = tonumber(itemId)
if not key then
return false
end
local row = store.unknownFoods.byItemId[key]
if type(row) ~= "table" then
return false
end
local verdict = evidence and MTH_FEED_SafeLower(evidence.verdict or "") or ""
if verdict == "confirmed-food" then
row.state = "confirmed-food"
row.confidence = 1
if evidence.foodLevel ~= nil then
row.resolved.foodLevel = tonumber(evidence.foodLevel)
end
if evidence.familyMask ~= nil then
row.resolved.familyMask = evidence.familyMask
end
row.resolved.resolvedFrom = evidence.resolvedFrom or "runtime-observation"
row.resolved.resolvedAt = MTH_FEED_Now()
elseif verdict == "confirmed-not-food" then
row.state = "confirmed-not-food"
row.confidence = 1
elseif verdict == "candidate-food" then
row.state = "candidate-food"
row.confidence = math.max(tonumber(row.confidence) or 0, 0.5)
end
MTH_FEED_TouchStore(store)
return true
end
function MTH_FEED_GetUnknownFoodQueue()
local store = MTH_FEED_EnsureStore()
local rows = {}
for _, row in pairs(store.unknownFoods.byItemId) do
if type(row) == "table" and row.state ~= "confirmed-food" and row.state ~= "confirmed-not-food" then
table.insert(rows, row)
end
end
table.sort(rows, function(a, b)
return (tonumber(a.lastSeenAt) or 0) > (tonumber(b.lastSeenAt) or 0)
end)
return rows
end
function MTH_FEED_ResolveFoodMeta(itemId, payload)
local key = tonumber(itemId)
if not key then
MTH_FEED_Trace("ResolveFoodMeta ignored: invalid itemId")
return false
end
local store = MTH_FEED_EnsureStore()
if type(store.unknownFoods.byItemId[key]) ~= "table" then
MTH_FEED_RecordUnknownFoodCandidate({
itemId = key,
itemName = payload and payload.itemName or nil,
itemLink = MTH_FEED_NormalizeItemLink(key, payload and payload.itemName or nil, payload and payload.itemLink or nil),
source = "manual-resolve",
observedFoodLevel = payload and payload.foodLevel or nil,
observedDietHint = nil,
evidenceType = "manual",
})
end
local row = store.unknownFoods.byItemId[key]
if type(row.resolved) ~= "table" then
row.resolved = {}
end
row.state = "confirmed-food"
row.confidence = 1
row.resolved.foodLevel = payload and tonumber(payload.foodLevel) or nil
row.resolved.familyMask = payload and payload.familyMask or nil
row.resolved.resolvedFrom = payload and payload.resolvedFrom or "manual"
row.resolved.resolvedAt = MTH_FEED_Now()
MTH_FEED_Trace("ResolveFoodMeta itemId=" .. tostring(key)
.. " foodLevel=" .. tostring(row.resolved.foodLevel)
.. " resolvedFrom=" .. tostring(row.resolved.resolvedFrom))
MTH_FEED_TouchStore(store)
return true
end
function MTH_FEED_DebugDumpCurrentPet(limit)
local ctx = MTH_FEED_GetCurrentPetContext()
if type(ctx) ~= "table" then
MTH_FEED_Trace("DebugDumpCurrentPet: no active pet context")
return false
end
local stats = MTH_FEED_GetPetFeedStats(ctx.petId)
if type(stats) ~= "table" then
MTH_FEED_Trace("DebugDumpCurrentPet: no stats for petId=" .. tostring(ctx.petId))
return false
end
local totals = stats.totals or {}
MTH_FEED_Trace("DebugDumpCurrentPet petId=" .. tostring(ctx.petId)
.. " name='" .. tostring(stats.petName or "") .. "'"
.. " attempts=" .. tostring(tonumber(totals.attempts) or 0)
.. " accepted=" .. tostring(tonumber(totals.accepted) or 0)
.. " rejected=" .. tostring(tonumber(totals.rejected) or 0)
.. " noBuff=" .. tostring(tonumber(totals.rejectedNoBuff) or 0)
.. " lowLevel=" .. tostring(tonumber(totals.rejectedLowLevel) or 0)
.. " wrongFood=" .. tostring(tonumber(totals.rejectedWrongFood) or 0))
local maxRows = tonumber(limit) or 5
if maxRows < 1 then maxRows = 1 end
if maxRows > 20 then maxRows = 20 end
local recent = stats.recent or {}
for i = 1, maxRows do
local row = recent[i]
if type(row) ~= "table" then
break
end
MTH_FEED_Trace("Recent#" .. tostring(i)
.. " itemId=" .. tostring(row.itemId)
.. " foodLevel=" .. tostring(row.foodLevel)
.. " petLevel=" .. tostring(row.petLevel)
.. " outcome=" .. tostring(row.outcome)
.. " reason=" .. tostring(row.reason)
.. " source=" .. tostring(row.source))
end
return true
end
MTH_FEED_InstallCoreHooks()
MTH_FEED_EnsureBootstrapFrame()