diff --git a/AllTheThings.lua b/AllTheThings.lua index 5a99baf395..3d08206915 100644 --- a/AllTheThings.lua +++ b/AllTheThings.lua @@ -4918,612 +4918,6 @@ local function GetPopulatedQuestObject(questID) return questObject; end --- Achievement Lib -do -local GetAchievementCategory, GetAchievementNumCriteria, GetCategoryInfo, GetStatistic = GetAchievementCategory, GetAchievementNumCriteria, GetCategoryInfo, GetStatistic; -local cache = app.CreateCache("achievementID"); -local function CacheInfo(t, field) - local _t, id = cache.GetCached(t); - --local IDNumber, Name, Points, Completed, Month, Day, Year, Description, Flags, Image, RewardText, isGuildAch = GetAchievementInfo(t.achievementID); - local _, name, _, _, _, _, _, _, _, icon = GetAchievementInfo(id); - _t.link = GetAchievementLink(id); - _t.name = name or ("Achievement #"..id); - _t.icon = icon or QUESTION_MARK_ICON; - if field then return _t[field]; end -end -local function OnUpdateWindows() - app.HandleEvent("OnUpdateWindows") -end -local function DelayedOnUpdateWindows() - AfterCombatOrDelayedCallback(OnUpdateWindows, 1) -end -app.AddEventRegistration("RECEIVED_ACHIEVEMENT_LIST", DelayedOnUpdateWindows); -local fields = { - ["key"] = function(t) - return "achievementID"; - end, - ["achievementID"] = function(t) - local achievementID = t.altAchID and app.FactionID == Enum.FlightPathFaction.Horde and t.altAchID or t.achID; - if achievementID then - t.achievementID = achievementID; - return achievementID; - end - end, - ["link"] = function(t) - return cache.GetCachedField(t, "link", CacheInfo); - end, - ["name"] = function(t) - return cache.GetCachedField(t, "name", CacheInfo); - end, - ["icon"] = function(t) - return cache.GetCachedField(t, "icon", CacheInfo); - end, - ["collectible"] = function(t) - return app.Settings.Collectibles.Achievements; - end, - ["collected"] = function(t) - if t.saved then return 1; end - if app.Settings.AccountWide.Achievements then - local id = t.achievementID; - -- cached account-wide credit, or API account-wide credit - if ATTAccountWideData.Achievements[id] then return 2; end - local acctApiCredit = select(4, GetAchievementInfo(id)); - if acctApiCredit then - return 2; - end - end - end, - ["trackable"] = app.ReturnTrue, - ["saved"] = function(t) - local id = t.achievementID; - if app.CurrentCharacter.Achievements[id] then return true; end - local earnedByMe = select(13, GetAchievementInfo(id)); - if earnedByMe then - app.CurrentCharacter.Achievements[id] = 1; - ATTAccountWideData.Achievements[id] = 1; - return true; - end - end, - ["parentCategoryID"] = function(t) - return GetAchievementCategory(t.achievementID) or -1; - end, - ["statistic"] = function(t) - if GetAchievementNumCriteria(t.achievementID) == 1 then - local quantity, reqQuantity = select(4, GetAchievementCriteriaInfo(t.achievementID, 1)); - if quantity and reqQuantity and reqQuantity > 1 then - return tostring(quantity) .. " / " .. tostring(reqQuantity); - end - end - ---@diagnostic disable-next-line: missing-parameter - local statistic = GetStatistic(t.achievementID); - if statistic and statistic ~= '0' and statistic ~= '' and not statistic:match("%W") then - return statistic; - end - end, - ["sortProgress"] = function(t) - if t.collected then - return 1; - end - -- only calculate achievement progress using achievements where the single criteria is the 'progress bar' - if GetAchievementNumCriteria(t.achievementID) == 1 then - local quantity, reqQuantity = select(4, GetAchievementCriteriaInfo(t.achievementID, 1)); - if quantity and reqQuantity and reqQuantity > 1 then - -- print("ach-prog",t.achievementID,quantity,reqQuantity); - return (quantity / reqQuantity); - end - end - return 0; - end, - ["back"] = function(t) - return t.sourceIgnored and 0.5 or 0; - end, -}; -app.BaseAchievement = app.BaseObjectFields(fields, "BaseAchievement"); -app.CreateAchievement = function(id, t) - return setmetatable(constructor(id, t, "achID"), app.BaseAchievement); -end -app.CreateGuildAchievement = function(id, t) - -- TODO: Proper Class Extension Maybe? I think the Achievement class doesn't use a Class Constructor yet, but when it does, do this too. - t = app.CreateAchievement(id, t); - t.collectible = false; - t.isGuild = true; - return t; -end - --- Achievement Category Lib -local categoryFields = { - ["key"] = function(t) - return "achievementCategoryID"; - end, - ["name"] = function(t) - return GetCategoryInfo(t.achievementCategoryID); - end, - ["icon"] = function(t) - return app.asset("Category_Achievements"); - end, - ["parentCategoryID"] = function(t) - return select(2, GetCategoryInfo(t.achievementCategoryID)) or -1; - end, -}; -app.BaseAchievementCategory = app.BaseObjectFields(categoryFields, "BaseAchievementCategory"); -app.CreateAchievementCategory = function(id, t) - return setmetatable(constructor(id, t, "achievementCategoryID"), app.BaseAchievementCategory); -end - --- Achievement Criteria Lib -local GetAchievementCriteriaInfoByID - = GetAchievementCriteriaInfoByID --- Criteria field values which will use the value of the respective Achievement instead -local UseParentAchievementValueKeys = { - "c", "classID", "races", "r", "u", "e", "pb", "pvp", "requireSkill" -} -local function GetParentAchievementInfo(t, key) - -- if the Achievement data was already cached, but the criteria is still getting here - -- then the Achievement's data field was nil - if t._cached then return nil; end - local id = t.achievementID; - if not id then - app.PrintDebug("Missing achievementID for criteria reference",t.hash) - return; - end - local achievement = SearchForObject("achievementID", id, "key"); - if achievement then - -- copy parent Achievement field re-mappings - for _,key in ipairs(UseParentAchievementValueKeys) do - t[key] = achievement[key] - end - t._cached = true; - return rawget(t, key); - end - DelayedCallback(app.report, 1, "Missing Referenced Achievement!",id); -end --- Returns expected criteria data for either criteriaIndex or criteriaID -local function GetCriteriaInfo(achievementID, t) - -- prioritize the correct id - local critUID = t.uid or t.criteriaID - local critID = t.id or critUID - local criteriaString, criteriaType, completed, quantity, reqQuantity, charName, flags, assetID, quantityString, criteriaID, eligible - = GetAchievementCriteriaInfoByID(achievementID, critUID) - if IsRetrieving(criteriaString) and critID <= GetAchievementNumCriteria(achievementID) then - criteriaString, criteriaType, completed, quantity, reqQuantity, charName, flags, assetID, quantityString, criteriaID, eligible - ---@diagnostic disable-next-line: redundant-parameter - = GetAchievementCriteriaInfo(achievementID, critID, true) - end - return criteriaString, criteriaType, completed, quantity, reqQuantity, charName, flags, assetID, quantityString, criteriaID, eligible -end -local function default_name(t) - if t.link then return t.link; end - local name - local achievementID = t.achievementID; - if achievementID then - local criteriaID = t.criteriaID; - if criteriaID then - -- typical criteria name lookup - name = GetCriteriaInfo(achievementID, t); - if not IsRetrieving(name) then return name; end - - -- app.PrintDebug("fallback crit name",achievementID,criteriaID,t.uid,t.id) - -- criteria nested under a parent of a known Thing - local parent = t.parent - if parent and parent.key and app.ThingKeys[parent.key] and parent.key ~= "achievementID" then - name = parent.name - if not IsRetrieving(name) and not name:find("Quest #") then return name; end - end - - -- criteria with provider data - local providers = t.providers; - if providers then - for k,v in ipairs(providers) do - if v[2] > 0 then - if v[1] == "o" then - name = app.ObjectNames[v[2]]; - break - elseif v[1] == "i" then - name = GetItemInfo(v[2]); - break - elseif v[1] == "n" then - name = app.NPCNameFromID[v[2]]; - break - end - end - end - if not IsRetrieving(name) then return name; end - end - - -- criteria with sourceQuests data - local sourceQuests = t.sourceQuests; - if sourceQuests then - for k,id in ipairs(sourceQuests) do - name = app.GetQuestName(id); - t.__questname = name - if not IsRetrieving(name) and not name:find("Quest #") then return name; end - end - -- app.PrintDebug("criteria sq no name",t.achievementID,t.criteriaID,rawget(t,"name")) - return - end - - -- criteria with spellID (TODO) - - -- criteria fallback to base achievement name - name = "Criteria: "..(select(2, GetAchievementInfo(achievementID)) or "#"..criteriaID) - end - end - app.PrintDebug("failed to retrieve criteria name",achievementID,t.criteriaID,name,t._default_name_retry) - t._default_name_retry = (t._default_name_retry or 0) + 1 - if (t._default_name_retry > 25) then - t._default_name_retry = nil - return name or UNKNOWN - end -end -local cache = app.CreateCache("hash") -local criteriaFields = { - ["key"] = function(t) - return "criteriaID"; - end, - ["achievementID"] = function(t) - local achievementID = t.altAchID and app.FactionID == Enum.FlightPathFaction.Horde and t.altAchID or t.achID; - if achievementID then - t.achievementID = achievementID; - return achievementID; - end - local sourceAch = t.sourceParent or t.parent; - achievementID = sourceAch and (sourceAch.achievementID or (sourceAch.parent and sourceAch.parent.achievementID)); - if achievementID then - t.achievementID = achievementID; - return achievementID; - end - end, - ["name"] = function(t) - return cache.GetCachedField(t, "name", default_name) or t.__questname - end, - ["link"] = function(t) - if t.itemID then - local _, link, _, _, _, _, _, _, _, icon = GetItemInfo(t.itemID); - if link then - t.text = link; - t.link = link; - t.icon = icon; - return link; - end - end - end, - ["trackable"] = app.ReturnTrue, - ["collected"] = function(t) - if t.saved then return 1; end - if app.Settings.AccountWide.Achievements then - local achievementID = t.achievementID; - -- cached account-wide credit, or API account-wide credit - if achievementID then - if ATTAccountWideData.Achievements[achievementID] then return 2; end - local acctApiCredit = select(4, GetAchievementInfo(achievementID)); - if acctApiCredit then - return 2; - end - end - end - end, - ["saved"] = function(t) - local achievementID = t.achievementID; - if achievementID then - if app.CurrentCharacter.Achievements[achievementID] then return true; end - local criteriaID = t.criteriaID; - if criteriaID then - return select(3, GetCriteriaInfo(achievementID, t)); - end - end - end, - ["index"] = function(t) - return 1; - end, -}; -criteriaFields.collectible = fields.collectible; -criteriaFields.icon = fields.icon; --- apply parent Achievement field re-mappings -for _,key in ipairs(UseParentAchievementValueKeys) do - criteriaFields[key] = function(t) - return GetParentAchievementInfo(t, key); - end -end -local BaseAchievementCriteria = app.BaseObjectFields(criteriaFields, "BaseAchievementCriteria"); -app.CreateAchievementCriteria = function(id, t, init) - t = setmetatable(constructor(id, t, "criteriaID"), BaseAchievementCriteria); - if init then - GetParentAchievementInfo(t, ""); - -- app.PrintDebug("CreateAchievementCriteria.Init",t.hash) - end - return t; -end -app.CreateGuildAchievementCriteria = function(id, t) - -- TODO: Proper Class Extension Maybe? I think the Achievement class doesn't use a Class Constructor yet, but when it does, do this too. - t = app.CreateAchievementCriteria(id, t); - t.collectible = false; - t.isGuild = true; - return t; -end - -local HarvestedAchievementDatabase = {}; -local harvesterFields = RawCloneData(fields); -harvesterFields.visible = app.ReturnTrue; -harvesterFields.collectible = app.ReturnTrue; -harvesterFields.collected = app.ReturnFalse; -harvesterFields.text = function(t) - local achievementID = t.achievementID; - if achievementID then - local IDNumber, Name, _, _, _, _, _, Description, _, Image, _, isGuildAch = GetAchievementInfo(achievementID); - if Name then - local info = { - ["name"] = Name, - ["achievementID"] = IDNumber, - ["parentCategoryID"] = GetAchievementCategory(achievementID) or -1, - ["icon"] = Image, - ["isGuild"] = isGuildAch and true or nil, - }; - if Description ~= nil and Description ~= "" then - info.description = Description; - end - local totalCriteria = GetAchievementNumCriteria(achievementID); - if totalCriteria > 0 then - local criteria = {}; - for criteriaID=totalCriteria,1,-1 do - ---@diagnostic disable-next-line: redundant-parameter - local criteriaString, criteriaType, _, _, reqQuantity, _, flags, assetID, _, criteriaUID = GetAchievementCriteriaInfo(achievementID, criteriaID, true); - local crit = { ["criteriaID"] = criteriaID, ["criteriaUID"] = criteriaUID }; - if criteriaString ~= nil and criteriaString ~= "" then - crit.name = criteriaString; - end - if assetID and assetID ~= 0 then - crit.assetID = assetID; - end - if reqQuantity and reqQuantity > 0 then - crit.rank = reqQuantity; - end - if criteriaType then - -- Unknown type, not sure what to do with this. - crit.criteriaType = criteriaType; - if crit.assetID then - if criteriaType == 27 then -- Quest Completion - crit._quests = { assetID }; - crit.criteriaType = nil; - crit.assetID = nil; - if crit.rank and crit.rank == 1 then - crit.rank = nil; - end - elseif criteriaType == 36 or criteriaType == 41 or criteriaType == 42 then - -- 36: Items (Generic) - -- 41: Items (Use/Eat) - -- 42: Items (Loot) - if crit.rank and crit.rank < 2 then - crit.provider = { "i", crit.assetID }; - else - crit.cost = { { "i", crit.assetID, crit.rank }}; - end - crit.criteriaType = nil; - crit.assetID = nil; - crit.rank = nil; - elseif criteriaType == 43 then -- Exploration?! - crit.explorationID = crit.assetID; - crit.criteriaType = nil; - crit.assetID = nil; - crit.rank = nil; - elseif criteriaType == 0 then -- NPC Kills - crit._npcs = { crit.assetID }; - if crit.rank and crit.rank < 2 then - crit.rank = nil; - end - crit.criteriaType = nil; - crit.assetID = nil; - elseif criteriaType == 96 then -- Collect Pets - crit._npcs = { crit.assetID }; - if crit.rank and crit.rank < 2 then - crit.rank = nil; - end - crit.criteriaType = nil; - crit.assetID = nil; - elseif criteriaType == 68 or criteriaType == 72 then -- Interact with Object (68) / Fish from a School (72) - crit._objects = { crit.assetID }; - if crit.rank and crit.rank < 2 then - crit.rank = nil; - end - crit.criteriaType = nil; - crit.assetID = nil; - elseif criteriaType == 7 then -- Skill ID, Rank is Requirement - crit.requireSkill = crit.assetID; - crit.criteriaType = nil; - crit.assetID = nil; - elseif criteriaType == 40 then -- Skill ID Learned - crit.requireSkill = crit.assetID; - crit.criteriaType = nil; - crit.assetID = nil; - crit.rank = nil; - elseif criteriaType == 8 then -- Achievements as Children - crit._achievements = { crit.assetID }; - if crit.rank and crit.rank < 2 then - crit.rank = nil; - end - crit.criteriaType = nil; - crit.assetID = nil; - elseif criteriaType == 12 then -- Currencies (Collected Total) - if crit.rank and crit.rank < 2 then - crit.cost = { { "c", crit.assetID, 1 }}; - else - crit.cost = { { "c", crit.assetID, crit.rank }}; - end - crit.criteriaType = nil; - crit.assetID = nil; - crit.rank = nil; - elseif criteriaType == 26 then - -- 26: Environmental Deaths - -- 0: fatigue - -- 1: drowning - -- 2: falling - -- 3/5: fire/lava - -- https://wowwiki-archive.fandom.com/wiki/API_GetAchievementCriteriaInfo - if crit.rank and totalCriteria == 1 then - info.rank = crit.rank; - end - elseif criteriaType == 29 or criteriaType == 69 then -- Cast X Spell Y Times - if crit.rank and totalCriteria == 1 then - info.rank = crit.rank; - else - crit.spellID = crit.assetID; - crit.criteriaType = nil; - crit.assetID = nil; - end - elseif criteriaType == 46 then -- Minimum Faction Requirement - crit.minReputation = { crit.assetID, crit.rank }; - crit.criteriaType = nil; - crit.assetID = nil; - crit.rank = nil; - end - -- 28: Something to do with event-based encounters, not sure what assetID is. - -- 49: Something to do with Equipment Slots, assetID is the equipSlotID. (useless maybe?) - -- 52: Honorable kill on a specific Class, assetID is the ClassID. (useless maybe? might be able to use a class icon?) - -- 53: Honorable kill on a specific Class at level 35+, assetID is the ClassID. (useless maybe? might be able to use a class icon?) - -- 54: Show a critter you /love them, assetID is useless or not present. - -- 70: Honorable Kill at a specific place. - -- 71: Instance Clears, assetID is of an unknown type... might be Saved Instance ID? - -- 73: Mal'Ganis? Complete Objective? (useless) - -- 74: No idea, tracking of some kind - -- 92: Encounter Kills, of non-NPC type. (Group of NPCs - IE: Lilian Voss) - elseif criteriaType == 0 or criteriaType == 3 or criteriaType == 5 or criteriaType == 6 or criteriaType == 9 or criteriaType == 10 or criteriaType == 14 or criteriaType == 15 or criteriaType == 17 or criteriaType == 19 or criteriaType == 26 or criteriaType == 37 or criteriaType == 45 or criteriaType == 75 or criteriaType == 78 or criteriaType == 79 or criteriaType == 81 or criteriaType == 90 or criteriaType == 91 or criteriaType == 109 or criteriaType == 124 or criteriaType == 126 or criteriaType == 130 or criteriaType == 134 or criteriaType == 135 or criteriaType == 136 or criteriaType == 138 or criteriaType == 139 or criteriaType == 151 or criteriaType == 156 or criteriaType == 157 or criteriaType == 158 or criteriaType == 200 or criteriaType == 203 or criteriaType == 207 then - -- 0: Some tracking statistic, generally X/Y format and simple enough to not justify a type if no assetID is present. - -- 3: Collect X of something that's generic for Archeology - -- 5: Level Requirement - -- 6: Digsites (Archeology) - -- 9: Total Quests Completed - -- 10: Daily Quests, every day for X days. - -- 14: Total Daily Quests Completed - -- 15: Battleground battles - -- 17: Total Deaths - -- 19: Instances Run - -- 26: Environmental Deaths - -- 37: Ranked Arena Wins - -- 45: Bank Slots Purchased - -- 75: Mounts (Total - on one Character) - -- 78: Kill NPCs - -- 79: Cook Food - -- 81: Pet battle achievement points - -- 90: Gathering (Nodes) - -- 91: Pet Charm Totals - -- 109: Catch Fish - -- 124: Guild Member Repairs - -- 126: Guild Crafting - -- 130: Rated Battleground Wins - -- 134: Complete Quests - -- 135: Honorable Kills (Total) - -- 136: Kill Critters - -- 138: Guild Scenario Challenges Completed - -- 139: Guild Challenges Completed - -- 151: Guild Scenario Completed - -- 156: Collect Pets (Total) - -- 157: Collect Pets (Rare) - -- 158: Pet Battles - -- 200: Recruit Troops - -- 203: World Quests (Total Complete) - -- 207: Honor Earned (Total) - -- https://wowwiki-archive.fandom.com/wiki/API_GetAchievementCriteriaInfo - if crit.rank and totalCriteria == 1 then - info.rank = crit.rank; - end - elseif criteriaType == 38 or criteriaType == 39 or criteriaType == 58 or criteriaType == 63 or criteriaType == 65 or criteriaType == 66 or criteriaType == 76 or criteriaType == 77 or criteriaType == 82 or criteriaType == 83 or criteriaType == 84 or criteriaType == 85 or criteriaType == 86 or criteriaType == 107 or criteriaType == 128 or criteriaType == 152 or criteriaType == 153 or criteriaType == 163 then -- Ignored - -- 38: Team Rating, which is irrelevant. - -- 39: Personal Rating, which is irrelevant. - -- 58: Killing Blows, might specifically be PvP. - -- 63: Total Gold (Spent on Travel) - -- 65: Total Gold (Spent on Barber Shop) - -- 66: Total Gold (Spent on Mail) - -- 76: Duels Won - -- 77: Duels Lost - -- 82: Auctions (Total Posted) - -- 83: Auctions (Highest Bid) - -- 84: Auctions (Total Purchases) - -- 85: Auctions (Highest Sold)] - -- 86: Most Gold Ever Owned - -- 107: Quests Abandoned - -- 128: Guild Bank Tabs - -- 152: Defeat Scenarios - -- 153: Ride to Location? - -- 163: Also ride to location - break; - elseif criteriaType == 59 or criteriaType == 62 or criteriaType == 67 or criteriaType == 80 then -- Gold Cost, if available. - -- 59: Total Gold (Vendors) - -- 62: Total Gold (Quest Rewards) - -- 67: Total Gold (Looted) - -- 80: Total Gold (Auctions) - if crit.rank and crit.rank > 1 then - if totalCriteria == 1 then - -- Generic, such as the Bread Winner - info.rank = crit.rank; - else - crit.cost = { { "g", crit.assetID, crit.rank } }; - crit.criteriaType = nil; - crit.assetID = nil; - info.rank = nil; - end - else - -- nothing - end - end - -- 155: Collect Battle Pets from a Raid, no assetID though RIP - -- 158: Defeat Master Trainers - -- 161: Capture a Battle Pet in a Zone - -- 163: Defeat an Encounter of some kind? AssetID useless - -- 169: Construct a building, assetID might be the buildingID. - end - tinsert(criteria, 1, crit); - end - if #criteria > 0 then info.criteria = criteria; end - end - - HarvestedAchievementDatabase[achievementID] = info; - setmetatable(t, app.BaseAchievement); - t.collected = true; - return Name; - end - -- Save an empty value just so the Saved Variable table is always in order for easier partial-replacements if needed - HarvestedAchievementDatabase[achievementID] = 0; - end - - AllTheThingsHarvestItems = HarvestedAchievementDatabase; - local name = t.name; - -- retries exceeded, so check the raw .name on the group (gets assigned when retries exceeded during cache attempt) - if name then t.collected = true; end - return name; -end -app.BaseAchievementHarvester = app.BaseObjectFields(harvesterFields, "BaseAchievementHarvester"); -app.CreateAchievementHarvester = function(id, t) - return setmetatable(constructor(id, t, "achievementID"), app.BaseAchievementHarvester); -end - --- TODO: migrate this achievement refresh to proper handling within Achievement lib -local function CheckAchievementCollectionStatus(achievementID) - if ATTAccountWideData then - achievementID = tonumber(achievementID) or achievementID; - local _,_,_,acctCredit,_,_,_,_,_,_,_,isGuild,earnedByMe = GetAchievementInfo(achievementID); - if earnedByMe then - app.CurrentCharacter.Achievements[achievementID] = 1; - ATTAccountWideData.Achievements[achievementID] = 1; - elseif acctCredit and not isGuild then - ATTAccountWideData.Achievements[achievementID] = 1; - end - end -end -local function RefreshAchievementCollection() - if ATTAccountWideData then - local maxid, achID = 0, 0; - for achievementID,_ in pairs(SearchForFieldContainer("achievementID")) do - achID = tonumber(achievementID) or achievementID; - if achID > maxid then maxid = achID; end - end - for achievementID=maxid,1,-1 do - CheckAchievementCollectionStatus(achievementID); - end - end -end -app.AddEventHandler("OnRefreshCollections", RefreshAchievementCollection) -app.AddEventRegistration("ACHIEVEMENT_EARNED", CheckAchievementCollectionStatus); -end -- Achievement Lib - -- Currency Lib (function() local C_CurrencyInfo_GetCurrencyInfo, C_CurrencyInfo_GetCurrencyLink diff --git a/AllTheThings.toc b/AllTheThings.toc index c457294eda..5c200d39d4 100644 --- a/AllTheThings.toc +++ b/AllTheThings.toc @@ -81,6 +81,7 @@ db\Presets.lua # Load object class templates src\Classes\base.lua +src\Classes\Achievement.lua src\Classes\BattlePet.lua src\Classes\CharacterClass.lua src\Classes\Difficulty.lua diff --git a/src/Classes/Achievement.lua b/src/Classes/Achievement.lua new file mode 100644 index 0000000000..e2c905943d --- /dev/null +++ b/src/Classes/Achievement.lua @@ -0,0 +1,626 @@ + +local _, app = ... + +-- Globals +local setmetatable, rawget, select, tostring, ipairs, pairs, tinsert, tonumber + = setmetatable, rawget, select, tostring, ipairs, pairs, tinsert, tonumber + +-- WoW API Cache +local GetAchievementNumCriteria,GetAchievementInfo,GetAchievementLink,GetAchievementCriteriaInfo,GetAchievementCategory + = GetAchievementNumCriteria,GetAchievementInfo,GetAchievementLink,GetAchievementCriteriaInfo,GetAchievementCategory +local GetItemInfo = app.WOWAPI.GetItemInfo + +-- Module +local DelayedCallback = app.CallbackHandlers.DelayedCallback; +local AfterCombatOrDelayedCallback = app.CallbackHandlers.AfterCombatOrDelayedCallback; +local IsRetrieving = app.Modules.RetrievingData.IsRetrieving; +local SearchForObject + = app.SearchForObject + +-- App + +local CollectionCacheFunctions = { + MaxAchievementID = function() + local maxid, achID = 0, 0; + for id,_ in pairs(app.GetRawFieldContainer("achievementID")) do + achID = tonumber(id) or id + if achID > maxid then maxid = achID; end + end + return maxid + end +} +local CollectionCache = setmetatable({}, { __index = function(t, key) + local func = CollectionCacheFunctions[key] + if func then + local val = func() + t[key] = val + return val + end +end}) + +local AchievementClass +-- Achievement Lib +do + local KEY, CACHE = "achievementID", "Achievements" + local GetStatistic + = GetStatistic + + local cache = app.CreateCache(KEY); + local function CacheInfo(t, field) + local _t, id = cache.GetCached(t); + --local IDNumber, Name, Points, Completed, Month, Day, Year, Description, Flags, Image, RewardText, isGuildAch = GetAchievementInfo(t[KEY]); + local _, name, _, _, _, _, _, _, _, icon = GetAchievementInfo(id); + _t.link = GetAchievementLink(id); + _t.name = name or ("Achievement #"..id); + _t.icon = icon or QUESTION_MARK_ICON; + if field then return _t[field]; end + end + -- This was used to update information about achievement progress following Pet Battles + -- This unfortunately triggers all the time and rarely actually represents useful Achievement changes + -- TODO: Think of another way to represent Achievement changes post Pet Battles + -- local function OnUpdateWindows() + -- app.HandleEvent("OnUpdateWindows") + -- end + -- local function DelayedOnUpdateWindows() + -- AfterCombatOrDelayedCallback(OnUpdateWindows, 1) + -- end + -- app.AddEventRegistration("RECEIVED_ACHIEVEMENT_LIST", DelayedOnUpdateWindows); + app.CreateAchievement, AchievementClass = app.CreateClass("Achievement", KEY, { + link = function(t) + return cache.GetCachedField(t, "link", CacheInfo); + end, + name = function(t) + return cache.GetCachedField(t, "name", CacheInfo); + end, + icon = function(t) + return cache.GetCachedField(t, "icon", CacheInfo); + end, + collectible = function(t) return app.Settings.Collectibles[CACHE] end, + collected = function(t) + local id = t[KEY]; + -- character collected + if app.IsCached(CACHE, id) then return 1; end + -- account-wide collected + if app.IsAccountTracked(CACHE, id) then return 2; end + end, + trackable = app.ReturnTrue, + saved = function(t) + local id = t[KEY]; + -- character collected + if app.IsCached(CACHE, id) then return 1; end + end, + parentCategoryID = function(t) + return GetAchievementCategory(t[KEY]) or -1; + end, + statistic = function(t) + if GetAchievementNumCriteria(t[KEY]) == 1 then + local quantity, reqQuantity = select(4, GetAchievementCriteriaInfo(t[KEY], 1)); + if quantity and reqQuantity and reqQuantity > 1 then + return tostring(quantity) .. " / " .. tostring(reqQuantity); + end + end + ---@diagnostic disable-next-line: missing-parameter + local statistic = GetStatistic(t[KEY]); + if statistic and statistic ~= '0' and statistic ~= '' and not statistic:match("%W") then + return statistic; + end + end, + sortProgress = function(t) + if t.collected then + return 1; + end + -- only calculate achievement progress using achievements where the single criteria is the 'progress bar' + if GetAchievementNumCriteria(t[KEY]) == 1 then + local quantity, reqQuantity = select(4, GetAchievementCriteriaInfo(t[KEY], 1)); + if quantity and reqQuantity and reqQuantity > 1 then + -- print("ach-prog",t.achievementID,quantity,reqQuantity); + return (quantity / reqQuantity); + end + end + return 0; + end, + back = function(t) + return t.sourceIgnored and 0.5 or 0; + end, + }) + + app.CreateGuildAchievement = function(id, t) + -- TODO: Proper Class Extension Maybe? I think the Achievement class doesn't use a Class Constructor yet, but when it does, do this too. + t = app.CreateAchievement(id, t); + t.collectible = false; + t.isGuild = true; + return t; + end + + app.AddEventHandler("OnRefreshCollections", function() + local state + local maxid = CollectionCache.MaxAchievementID + local saved, none = {}, {} + for id=1,maxid do + state = select(13, GetAchievementInfo(id)) + if state then + saved[id] = true + else + none[id] = true + end + end + -- Character Cache + app.SetBatchCached(CACHE, saved, 1) + app.SetBatchCached(CACHE, none) + -- Account Cache (removals handled by Sync) + app.SetBatchAccountCached(CACHE, saved, 1) + end); + app.AddEventHandler("OnSavedVariablesAvailable", function(currentCharacter, accountWideData) + if not currentCharacter[CACHE] then currentCharacter[CACHE] = {} end + if not accountWideData[CACHE] then accountWideData[CACHE] = {} end + end); + app.AddEventRegistration("ACHIEVEMENT_EARNED", function(id) + local state = select(13, GetAchievementInfo(tonumber(id))) + app.SetCached(CACHE, id, state) + app.UpdateRawID(KEY, id); + end); +end + +-- Achievement Category Lib +do + local GetCategoryInfo + = GetCategoryInfo + + app.CreateAchievementCategory = app.CreateClass("AchievementCategory", "achievementCategoryID", { + key = function(t) + return "achievementCategoryID"; + end, + name = function(t) + return GetCategoryInfo(t.achievementCategoryID); + end, + icon = function(t) + return app.asset("Category_Achievements"); + end, + parentCategoryID = function(t) + return select(2, GetCategoryInfo(t.achievementCategoryID)) or -1; + end, + }) +end + +-- Achievement Criteria Lib +do + local GetAchievementCriteriaInfoByID + = GetAchievementCriteriaInfoByID + + -- Returns expected criteria data for either criteriaIndex or criteriaID + local function GetCriteriaInfo(t, achievementID) + -- prioritize the correct id + local critUID = t.uid or t.criteriaID + local critID = t.id or critUID + achievementID = achievementID or t.achievementID + local criteriaString, criteriaType, completed, quantity, reqQuantity, charName, flags, assetID, quantityString, criteriaID, eligible + = GetAchievementCriteriaInfoByID(achievementID, critUID) + if IsRetrieving(criteriaString) and critID <= GetAchievementNumCriteria(achievementID) then + criteriaString, criteriaType, completed, quantity, reqQuantity, charName, flags, assetID, quantityString, criteriaID, eligible + ---@diagnostic disable-next-line: redundant-parameter + = GetAchievementCriteriaInfo(achievementID, critID, true) + end + return criteriaString, criteriaType, completed, quantity, reqQuantity, charName, flags, assetID, quantityString, criteriaID, eligible + end + + local QuickAchievementCache = setmetatable({}, { __index = function(t,key) + if not key then return end + local achObj = SearchForObject("achievementID", key, "key") + t[key] = achObj + return achObj + end}) + -- Criteria field values which will use the value of the respective Achievement instead + local UseParentAchievementValueKeys = { + "c", "classID", "races", "r", "u", "e", "pb", "pvp", "requireSkill", "icon" + } + local function GetParentAchievementInfo(t, key, _t) + -- if the Achievement data was already cached, but the criteria is still getting here + -- then the Achievement's data field was nil + if t._cached then return end + local id = t.achievementID + local achievement = QuickAchievementCache[id] + if achievement then + -- copy parent Achievement field re-mappings + for _,key in ipairs(UseParentAchievementValueKeys) do + _t[key] = achievement[key] + end + t._cached = true; + return rawget(_t, key); + end + DelayedCallback(app.report, 1, "Missing Referenced Achievement!",id); + end + local function default_name(t) + if t.link then return t.link; end + local name + local achievementID = t.achievementID + if achievementID then + local criteriaID = t.criteriaID; + if criteriaID then + -- typical criteria name lookup + name = GetCriteriaInfo(t, achievementID) + if not IsRetrieving(name) then return name; end + + -- app.PrintDebug("fallback crit name",achievementID,criteriaID,t.uid,t.id) + -- criteria nested under a parent of a known Thing + local parent = t.parent + if parent then + local parentKey = parent.key + if parentKey and app.ThingKeys[parentKey] and parentKey ~= "achievementID" then + name = parent.name + if not IsRetrieving(name) and not name:find("Quest #") then return name; end + end + end + + -- criteria with provider data + local providers = t.providers; + if providers then + local id + for k,v in ipairs(providers) do + id = v[2] + if id > 0 then + if v[1] == "o" then + name = app.ObjectNames[id]; + break + elseif v[1] == "i" then + name = GetItemInfo(id); + break + elseif v[1] == "n" then + name = app.NPCNameFromID[id]; + break + end + end + end + if not IsRetrieving(name) then return name; end + end + + -- criteria with sourceQuests data + local sourceQuests = t.sourceQuests; + if sourceQuests then + for k,id in ipairs(sourceQuests) do + name = app.GetQuestName(id); + t.__questname = name + if not IsRetrieving(name) and not name:find("Quest #") then return name; end + end + -- app.PrintDebug("criteria sq no name",achievementID,t.criteriaID,rawget(t,"name")) + return + end + + -- criteria with spellID (TODO) + + -- criteria fallback to base achievement name + name = "Criteria: "..(select(2, GetAchievementInfo(achievementID)) or "#"..criteriaID) + end + end + app.PrintDebug("failed to retrieve criteria name",achievementID,t.criteriaID,name,t._default_name_retry) + t._default_name_retry = (t._default_name_retry or 0) + 1 + if (t._default_name_retry > 25) then + t._default_name_retry = nil + return name or UNKNOWN + end + end + local cache = app.CreateCache("hash", "Criteria") + cache.DefaultFunctions.saved = function(t) + local saved = select(3, GetCriteriaInfo(t)) + -- only cache true values + if saved then return saved end + end + local criteriaFields = { + achievementID = function(t) + local achievementID = t.achID + t.achievementID = achievementID; + return achievementID; + end, + name = function(t) + return cache.GetCachedField(t, "name", default_name) or t.__questname + end, + link = function(t) + if t.itemID then + local _, link, _, _, _, _, _, _, _, icon = GetItemInfo(t.itemID); + if link then + t.text = link; + t.link = link; + t.icon = icon; + return link; + end + end + end, + collectible = function(t) return app.Settings.Collectibles.Achievements end, + collected = function(t) + -- character saved + if t.saved then return 1 end + local id = t.achievementID + -- account-wide collected achievement + if app.IsAccountTracked("Achievements", id) then return 2 end + end, + trackable = app.ReturnTrue, + saved = function(t) + local id = t.achievementID + -- character collected achievement + if app.IsCached("Achievements", id) then return 1 end + return cache.GetCachedField(t, "saved") + end, + index = function(t) + return 1; + end, + }; + -- apply parent Achievement field re-mappings + for _,key in ipairs(UseParentAchievementValueKeys) do + criteriaFields[key] = function(t) + return cache.GetCachedField(t, key, GetParentAchievementInfo) + end + end + app.CreateAchievementCriteria = app.CreateClass("Criteria", "criteriaID", criteriaFields) + app.CreateGuildAchievementCriteria = function(id, t) + -- TODO: Proper Class Extension Maybe? I think the Achievement class doesn't use a Class Constructor yet, but when it does, do this too. + t = app.CreateAchievementCriteria(id, t); + t.collectible = false; + t.isGuild = true; + return t; + end +end + + +-- Achievement Harvesting +local function RawCloneData(data, clone) + clone = clone or {}; + for key,value in pairs(data) do + if clone[key] == nil then + clone[key] = value + end + end + -- maybe better solution at another time? + clone.__type = nil; + clone.__index = nil; + return clone; +end +local HarvestedAchievementDatabase = {}; +local harvesterFields = RawCloneData(AchievementClass); +harvesterFields.visible = app.ReturnTrue; +harvesterFields.collectible = app.ReturnTrue; +harvesterFields.collected = app.ReturnFalse; +harvesterFields.text = function(t) + local achievementID = t.achievementID; + if achievementID then + local IDNumber, Name, _, _, _, _, _, Description, _, Image, _, isGuildAch = GetAchievementInfo(achievementID); + if Name then + local info = { + name = Name, + achievementID = IDNumber, + parentCategoryID = GetAchievementCategory(achievementID) or -1, + icon = Image, + isGuild = isGuildAch and true or nil, + }; + if Description ~= nil and Description ~= "" then + info.description = Description; + end + local totalCriteria = GetAchievementNumCriteria(achievementID); + if totalCriteria > 0 then + local criteria = {}; + for criteriaID=totalCriteria,1,-1 do + ---@diagnostic disable-next-line: redundant-parameter + local criteriaString, criteriaType, _, _, reqQuantity, _, _, assetID, _, criteriaUID = GetAchievementCriteriaInfo(achievementID, criteriaID, true); + local crit = { criteriaID = criteriaID, criteriaUID = criteriaUID }; + if criteriaString ~= nil and criteriaString ~= "" then + crit.name = criteriaString; + end + if assetID and assetID ~= 0 then + crit.assetID = assetID; + end + if reqQuantity and reqQuantity > 0 then + crit.rank = reqQuantity; + end + if criteriaType then + -- Unknown type, not sure what to do with this. + crit.criteriaType = criteriaType; + if crit.assetID then + if criteriaType == 27 then -- Quest Completion + crit._quests = { assetID }; + crit.criteriaType = nil; + crit.assetID = nil; + if crit.rank and crit.rank == 1 then + crit.rank = nil; + end + elseif criteriaType == 36 or criteriaType == 41 or criteriaType == 42 then + -- 36: Items (Generic) + -- 41: Items (Use/Eat) + -- 42: Items (Loot) + if crit.rank and crit.rank < 2 then + crit.provider = { "i", crit.assetID }; + else + crit.cost = { { "i", crit.assetID, crit.rank }}; + end + crit.criteriaType = nil; + crit.assetID = nil; + crit.rank = nil; + elseif criteriaType == 43 then -- Exploration?! + crit.explorationID = crit.assetID; + crit.criteriaType = nil; + crit.assetID = nil; + crit.rank = nil; + elseif criteriaType == 0 then -- NPC Kills + crit._npcs = { crit.assetID }; + if crit.rank and crit.rank < 2 then + crit.rank = nil; + end + crit.criteriaType = nil; + crit.assetID = nil; + elseif criteriaType == 96 then -- Collect Pets + crit._npcs = { crit.assetID }; + if crit.rank and crit.rank < 2 then + crit.rank = nil; + end + crit.criteriaType = nil; + crit.assetID = nil; + elseif criteriaType == 68 or criteriaType == 72 then -- Interact with Object (68) / Fish from a School (72) + crit._objects = { crit.assetID }; + if crit.rank and crit.rank < 2 then + crit.rank = nil; + end + crit.criteriaType = nil; + crit.assetID = nil; + elseif criteriaType == 7 then -- Skill ID, Rank is Requirement + crit.requireSkill = crit.assetID; + crit.criteriaType = nil; + crit.assetID = nil; + elseif criteriaType == 40 then -- Skill ID Learned + crit.requireSkill = crit.assetID; + crit.criteriaType = nil; + crit.assetID = nil; + crit.rank = nil; + elseif criteriaType == 8 then -- Achievements as Children + crit._achievements = { crit.assetID }; + if crit.rank and crit.rank < 2 then + crit.rank = nil; + end + crit.criteriaType = nil; + crit.assetID = nil; + elseif criteriaType == 12 then -- Currencies (Collected Total) + if crit.rank and crit.rank < 2 then + crit.cost = { { "c", crit.assetID, 1 }}; + else + crit.cost = { { "c", crit.assetID, crit.rank }}; + end + crit.criteriaType = nil; + crit.assetID = nil; + crit.rank = nil; + elseif criteriaType == 26 then + -- 26: Environmental Deaths + -- 0: fatigue + -- 1: drowning + -- 2: falling + -- 3/5: fire/lava + -- https://wowwiki-archive.fandom.com/wiki/API_GetAchievementCriteriaInfo + if crit.rank and totalCriteria == 1 then + info.rank = crit.rank; + end + elseif criteriaType == 29 or criteriaType == 69 then -- Cast X Spell Y Times + if crit.rank and totalCriteria == 1 then + info.rank = crit.rank; + else + crit.spellID = crit.assetID; + crit.criteriaType = nil; + crit.assetID = nil; + end + elseif criteriaType == 46 then -- Minimum Faction Requirement + crit.minReputation = { crit.assetID, crit.rank }; + crit.criteriaType = nil; + crit.assetID = nil; + crit.rank = nil; + end + -- 28: Something to do with event-based encounters, not sure what assetID is. + -- 49: Something to do with Equipment Slots, assetID is the equipSlotID. (useless maybe?) + -- 52: Honorable kill on a specific Class, assetID is the ClassID. (useless maybe? might be able to use a class icon?) + -- 53: Honorable kill on a specific Class at level 35+, assetID is the ClassID. (useless maybe? might be able to use a class icon?) + -- 54: Show a critter you /love them, assetID is useless or not present. + -- 70: Honorable Kill at a specific place. + -- 71: Instance Clears, assetID is of an unknown type... might be Saved Instance ID? + -- 73: Mal'Ganis? Complete Objective? (useless) + -- 74: No idea, tracking of some kind + -- 92: Encounter Kills, of non-NPC type. (Group of NPCs - IE: Lilian Voss) + elseif criteriaType == 0 or criteriaType == 3 or criteriaType == 5 or criteriaType == 6 or criteriaType == 9 or criteriaType == 10 or criteriaType == 14 or criteriaType == 15 or criteriaType == 17 or criteriaType == 19 or criteriaType == 26 or criteriaType == 37 or criteriaType == 45 or criteriaType == 75 or criteriaType == 78 or criteriaType == 79 or criteriaType == 81 or criteriaType == 90 or criteriaType == 91 or criteriaType == 109 or criteriaType == 124 or criteriaType == 126 or criteriaType == 130 or criteriaType == 134 or criteriaType == 135 or criteriaType == 136 or criteriaType == 138 or criteriaType == 139 or criteriaType == 151 or criteriaType == 156 or criteriaType == 157 or criteriaType == 158 or criteriaType == 200 or criteriaType == 203 or criteriaType == 207 then + -- 0: Some tracking statistic, generally X/Y format and simple enough to not justify a type if no assetID is present. + -- 3: Collect X of something that's generic for Archeology + -- 5: Level Requirement + -- 6: Digsites (Archeology) + -- 9: Total Quests Completed + -- 10: Daily Quests, every day for X days. + -- 14: Total Daily Quests Completed + -- 15: Battleground battles + -- 17: Total Deaths + -- 19: Instances Run + -- 26: Environmental Deaths + -- 37: Ranked Arena Wins + -- 45: Bank Slots Purchased + -- 75: Mounts (Total - on one Character) + -- 78: Kill NPCs + -- 79: Cook Food + -- 81: Pet battle achievement points + -- 90: Gathering (Nodes) + -- 91: Pet Charm Totals + -- 109: Catch Fish + -- 124: Guild Member Repairs + -- 126: Guild Crafting + -- 130: Rated Battleground Wins + -- 134: Complete Quests + -- 135: Honorable Kills (Total) + -- 136: Kill Critters + -- 138: Guild Scenario Challenges Completed + -- 139: Guild Challenges Completed + -- 151: Guild Scenario Completed + -- 156: Collect Pets (Total) + -- 157: Collect Pets (Rare) + -- 158: Pet Battles + -- 200: Recruit Troops + -- 203: World Quests (Total Complete) + -- 207: Honor Earned (Total) + -- https://wowwiki-archive.fandom.com/wiki/API_GetAchievementCriteriaInfo + if crit.rank and totalCriteria == 1 then + info.rank = crit.rank; + end + elseif criteriaType == 38 or criteriaType == 39 or criteriaType == 58 or criteriaType == 63 or criteriaType == 65 or criteriaType == 66 or criteriaType == 76 or criteriaType == 77 or criteriaType == 82 or criteriaType == 83 or criteriaType == 84 or criteriaType == 85 or criteriaType == 86 or criteriaType == 107 or criteriaType == 128 or criteriaType == 152 or criteriaType == 153 or criteriaType == 163 then -- Ignored + -- 38: Team Rating, which is irrelevant. + -- 39: Personal Rating, which is irrelevant. + -- 58: Killing Blows, might specifically be PvP. + -- 63: Total Gold (Spent on Travel) + -- 65: Total Gold (Spent on Barber Shop) + -- 66: Total Gold (Spent on Mail) + -- 76: Duels Won + -- 77: Duels Lost + -- 82: Auctions (Total Posted) + -- 83: Auctions (Highest Bid) + -- 84: Auctions (Total Purchases) + -- 85: Auctions (Highest Sold)] + -- 86: Most Gold Ever Owned + -- 107: Quests Abandoned + -- 128: Guild Bank Tabs + -- 152: Defeat Scenarios + -- 153: Ride to Location? + -- 163: Also ride to location + break; + elseif criteriaType == 59 or criteriaType == 62 or criteriaType == 67 or criteriaType == 80 then -- Gold Cost, if available. + -- 59: Total Gold (Vendors) + -- 62: Total Gold (Quest Rewards) + -- 67: Total Gold (Looted) + -- 80: Total Gold (Auctions) + if crit.rank and crit.rank > 1 then + if totalCriteria == 1 then + -- Generic, such as the Bread Winner + info.rank = crit.rank; + else + crit.cost = { { "g", crit.assetID, crit.rank } }; + crit.criteriaType = nil; + crit.assetID = nil; + info.rank = nil; + end + else + -- nothing + end + end + -- 155: Collect Battle Pets from a Raid, no assetID though RIP + -- 158: Defeat Master Trainers + -- 161: Capture a Battle Pet in a Zone + -- 163: Defeat an Encounter of some kind? AssetID useless + -- 169: Construct a building, assetID might be the buildingID. + end + tinsert(criteria, 1, crit); + end + if #criteria > 0 then info.criteria = criteria; end + end + + HarvestedAchievementDatabase[achievementID] = info; + setmetatable(t, app.BaseAchievement); + t.collected = true; + return Name; + end + -- Save an empty value just so the Saved Variable table is always in order for easier partial-replacements if needed + HarvestedAchievementDatabase[achievementID] = 0; + end + + AllTheThingsHarvestItems = HarvestedAchievementDatabase; + local name = t.name; + -- retries exceeded, so check the raw .name on the group (gets assigned when retries exceeded during cache attempt) + if name then t.collected = true; end + return name; +end +harvesterFields.IsClassIsolated = true +app.CreateAchievementHarvester = app.CreateClass("AchievementHarvester", "achievementID", harvesterFields) \ No newline at end of file diff --git a/src/Classes/base.lua b/src/Classes/base.lua index 2f5a318ec7..2c48c7db36 100644 --- a/src/Classes/base.lua +++ b/src/Classes/base.lua @@ -676,8 +676,9 @@ end -- Create a local cache table which can be used by a Type class of a Thing to easily store shared -- information based on a unique key field for any Thing object of that Type -app.CreateCache = function(idField) +app.CreateCache = function(idField, className) local cache, _t, v = {}, nil, nil; + cache.DefaultFunctions = {} cache.GetCached = function(t) local id = t[idField]; if id then @@ -700,14 +701,17 @@ app.CreateCache = function(idField) if _t then -- set a default provided cache value if any default function was provided and evalutes to a value v = _t[field]; - if not v and default_function then - local defVal = default_function(t, field); - if defVal then - v = defVal; - _t[field] = v; - end + if v ~= nil then return v end + + default_function = default_function or cache.DefaultFunctions[field] + if not default_function then return end + + local defVal = default_function(t, field, _t); + if defVal then + v = defVal; + _t[field] = v; end - return v; + return v end end; cache.SetCachedField = function(t, field, value) @@ -722,6 +726,9 @@ app.CreateCache = function(idField) _t = cache.GetCached(t); if _t then _t[field] = value; end end; + if app.__perf then + return app.__perf.AutoCaptureTable(cache, "ClassCache:"..(className or idField)) + end return cache; end