Files
RelationshipsAchievements/Events.lua
T
2026-07-23 12:19:21 +00:00

1356 lines
62 KiB
Lua

-- Event-driven achievement tracking, character scan, and hotkey binding.
local frame = CreateFrame("Frame", "RelationshipsAchievementsEventFrame", UIParent)
-- Turtle/OctoWoW builds expose slightly different event sets. Registering an
-- unknown event can abort an entire Lua file on some 1.12 clients, so optional
-- events are protected. The standard Vanilla events remain the primary path.
local function SafeRegister(name)
pcall(frame.RegisterEvent, frame, name)
end
local trackedEvents = {
"PLAYER_LOGIN", "PLAYER_ENTERING_WORLD", "PLAYER_LEVEL_UP", "PLAYER_DEAD",
"QUEST_COMPLETE", "QUEST_TURNED_IN", "ZONE_CHANGED_NEW_AREA", "ZONE_CHANGED",
"ZONE_CHANGED_INDOORS", "CHAT_MSG_COMBAT_HONOR_GAIN",
"CHAT_MSG_COMBAT_FACTION_CHANGE", "SKILL_LINES_CHANGED", "CHAT_MSG_LOOT",
"CHAT_MSG_MONEY", "CHAT_MSG_COMBAT_HOSTILE_DEATH", "CHAT_MSG_COMBAT_XP_GAIN",
"CHAT_MSG_SYSTEM", "UPDATE_FACTION", "PARTY_MEMBERS_CHANGED", "FRIENDLIST_UPDATE",
"GUILD_ROSTER_UPDATE", "PLAYER_UNGHOST", "PLAYER_ALIVE", "CHAT_MSG_TEXT_EMOTE",
"CHAT_MSG_EMOTE", "PLAYER_PVP_RANK_CHANGED", "PLAYER_PVP_KILLS_CHANGED",
"PLAYER_PVPKILLS_CHANGED", "HONOR_CURRENCY_UPDATE", "HEARTHSTONE_BOUND",
"BANKFRAME_OPENED", "TRAINER_SHOW", "MAIL_SEND_SUCCESS", "PLAYER_EQUIPMENT_CHANGED",
"BAG_UPDATE", "CHARACTER_POINTS_CHANGED", "SPELLS_CHANGED", "UNIT_HEALTH", "PLAYER_MONEY",
-- Vanilla 1.12 event names (PLAYER_EQUIPMENT_CHANGED does not exist here).
"UNIT_INVENTORY_CHANGED", "ADDON_LOADED", "MERCHANT_SHOW", "MERCHANT_CLOSED",
"TIME_PLAYED_MSG", "UNIT_PET", "UNIT_MODEL_CHANGED", "LEARNED_SPELL_IN_TAB",
-- OctoWoW / Turtle backported Companion API (Pet menu "P" -> Companions tab)
"COMPANION_LEARNED", "COMPANION_UPDATE", "COMPANION_UNLEARNED", "PET_UI_UPDATE"
}
for i = 1, table.getn(trackedEvents) do SafeRegister(trackedEvents[i]) end
-- ============ PvP rank scanning ============
-- Vanilla UnitPVPRank("player") returns 0 (no rank) or 5..19 where the
-- lowest ranked player is Private/Scout = 5. Subtract 4 to get 1..14.
local function ScanPvPRank()
if not UnitPVPRank then return end
local raw = UnitPVPRank("player") or 0
-- Lifetime stats preserve the highest rank ever earned, even after weekly
-- decay. Use whichever is higher so installing later still grants every
-- lower title in the chain.
if GetPVPLifetimeStats then
local _, _, highest = GetPVPLifetimeStats()
if highest and highest > raw then raw = highest end
end
if raw == 0 then return end
local rank = raw
if rank >= 5 then rank = rank - 4 end
if rank < 1 then return end
if rank >= 1 then RelationshipsAchievements:Unlock(2001) end -- Private / Scout
if rank >= 3 then RelationshipsAchievements:Unlock(2002) end -- Sergeant / Senior Sergeant
if rank >= 6 then RelationshipsAchievements:Unlock(2003) end -- Knight / Stone Guard
if rank >= 9 then RelationshipsAchievements:Unlock(2004) end -- Champion / Centurion
if rank >= 12 then RelationshipsAchievements:Unlock(2005) end -- Marshal / Legionnaire
if rank >= 13 then RelationshipsAchievements:Unlock(2006) end -- Field Marshal / Warlord
if rank >= 14 then
local _, faction = UnitFactionGroup("player")
if faction == "Horde" then
RelationshipsAchievements:Unlock(1003) -- High Warlord
else
RelationshipsAchievements:Unlock(1002) -- Grand Marshal
end
end
end
local function ScanPvPKills()
if not GetPVPLifetimeStats then return end
local honorable = GetPVPLifetimeStats() or 0
RelationshipsAchievements:UpdateStat("honorKills", honorable)
if honorable >= 1 then RelationshipsAchievements:Unlock(501) end
end
local function ScanTalents()
if not GetNumTalentTabs or not GetNumTalents or not GetTalentInfo then return end
local spent = 0
for tab = 1, GetNumTalentTabs() do
for talent = 1, GetNumTalents(tab) do
local _, _, _, _, rank = GetTalentInfo(tab, talent)
spent = spent + (rank or 0)
end
end
if spent >= 1 then RelationshipsAchievements:Unlock(1111) end
if spent >= 51 then RelationshipsAchievements:Unlock(1112) end
end
local previousTalentSpent
local function ScanTalentChange()
if not GetNumTalentTabs or not GetNumTalents or not GetTalentInfo then return end
local spent = 0
for tab = 1, GetNumTalentTabs() do
for talent = 1, GetNumTalents(tab) do
local _, _, _, _, rank = GetTalentInfo(tab, talent)
spent = spent + (rank or 0)
end
end
if previousTalentSpent and previousTalentSpent > 0 and spent == 0 then RelationshipsAchievements:Unlock(1113) end
previousTalentSpent = spent
ScanTalents()
end
-- Extract item quality from a link's color code. Robust when GetItemInfo has
-- not cached the item yet (common in 1.12 right after login or equip).
local QUALITY_BY_COLOR = {
["ff9d9d9d"] = 0, ["ffffffff"] = 1, ["ff1eff00"] = 2,
["ff0070dd"] = 3, ["ffa335ee"] = 4, ["ffff8000"] = 5,
}
local function QualityFromLink(link)
if not link then return nil end
local _, _, color = string.find(link, "|c(%x%x%x%x%x%x%x%x)")
if not color then return nil end
return QUALITY_BY_COLOR[string.lower(color)]
end
local function ScanEquipmentAndBags()
if GetInventoryItemLink then
if GetInventoryItemLink("player", 4) then RelationshipsAchievements:Unlock(1203) end
if GetInventoryItemLink("player", 19) then RelationshipsAchievements:Unlock(109) end
-- Well Equipped: every equipped armor/weapon slot must be Rare+.
-- Slots that are legitimately optional on a spec (shirt, tabard,
-- ranged, off-hand on 2H users) are skipped from the "all filled"
-- check but still count when present.
local requiredSlots = { 1,2,3,5,6,7,8,9,10,11,12,13,14,15,16 }
local optionalSlots = { 17, 18 } -- off-hand, ranged
local allRare, missing, epic = true, false, false
local function eval(slot, required)
local link = GetInventoryItemLink("player", slot)
if not link then
if required then missing = true end
return
end
local _, _, q = GetItemInfo(link)
local quality = q or QualityFromLink(link)
if quality and quality >= 4 then epic = true end
if required and (not quality or quality < 3) then allRare = false end
local itemName = GetItemInfo(link)
if itemName == "Atiesh, Greatstaff of the Guardian" then RelationshipsAchievements:Unlock(1004) end
if itemName == "Thunderfury, Blessed Blade of the Windseeker" then RelationshipsAchievements:Unlock(1005) end
if itemName == "Sulfuras, Hand of Ragnaros" then RelationshipsAchievements:Unlock(1006) end
end
for i = 1, table.getn(requiredSlots) do eval(requiredSlots[i], true) end
for i = 1, table.getn(optionalSlots) do eval(optionalSlots[i], false) end
if not missing and allRare then RelationshipsAchievements:Unlock(1208) end
if epic then RelationshipsAchievements:Unlock(1209) end
end
if GetContainerNumSlots then
local bags, has16 = 0, false
for bag = 1, 4 do
local slots = GetContainerNumSlots(bag) or 0
if slots > 0 then bags = bags + 1 end
if slots >= 16 then has16 = true end
end
if bags >= 4 then RelationshipsAchievements:Unlock(1202) end
if has16 then RelationshipsAchievements:Unlock(1201) end
end
end
-- Hidden tooltip used for spellbook scanning. Vanilla 1.12 does not expose
-- mount / non-combat-pet flags on spells, so we read the tooltip text — it
-- reliably distinguishes them across every locale-agnostic edge case we care
-- about because the mount / pet tooltip prefixes are stable in enUS clients.
local RA_ScanTip = CreateFrame("GameTooltip", "RelationshipsAchievementsScanTip", UIParent, "GameTooltipTemplate")
RA_ScanTip:SetOwner(UIParent, "ANCHOR_NONE")
-- ============ Known companion names (Vanilla + TurtleWoW / OctoWoW) ============
-- Any spell whose name matches one of these entries is credited as a
-- non-combat pet even when its tooltip doesn't contain the standard
-- keywords. Extend this list as new companions are added to the server.
local RA_KNOWN_COMPANIONS_LIST = {
-- Vanilla non-combat pets
"Ancona Chicken", "Azure Whelpling", "Bombay Cat", "Black Kingsnake",
"Black Tabby", "Blue Dragonhawk Hatchling", "Brown Prairie Dog",
"Brown Rabbit", "Brown Snake", "Calico Cat", "Cat Carrier (Bombay)",
"Cat Carrier (Black Tabby)", "Cat Carrier (Cornish Rex)",
"Cat Carrier (Orange Tabby)", "Cat Carrier (Siamese)",
"Cat Carrier (Silver Tabby)", "Cat Carrier (White Kitten)",
"Chicken Egg", "Cockroach", "Cornish Rex", "Crimson Whelpling",
"Dark Whelpling", "Disgusting Oozeling", "Emerald Whelpling",
"Firefly", "Frog", "Golden Dragonhawk Hatchling", "Great Horned Owl",
"Green Wing Macaw", "Hawk Owl", "Hyacinth Macaw", "Island Frog",
"Jubling", "Lifelike Mechanical Toad", "Lifelike Toad",
"Little Fawn", "Mana Wyrmling", "Mechanical Squirrel Box",
"Mini Diablo", "Mulgore Hatchling", "Orange Tabby",
"Panda Cub", "Parrot Cage (Green Wing Macaw)",
"Parrot Cage (Hyacinth Macaw)", "Parrot Cage (Senegal)",
"Peddlefeet", "Personal World Destroyer", "Prairie Dog Whistle",
"Red Dragonhawk Hatchling", "Sprite Darter Hatchling",
"Senegal", "Siamese", "Silver Tabby", "Smolderweb Hatchling",
"Snowshoe Rabbit", "Snowy Owl", "Speedy", "Stinker",
"Tiny Green Dragon", "Tiny Red Dragon", "Tranquil Mechanical Yeti",
"Tree Frog Box", "Turquoise Turtle", "Wanderer's Companion",
"Westfall Chicken", "White Kitten", "Whiskers the Rat",
"Winter Reindeer", "Winter's Little Helper", "Worg Carrier",
"Worg Pup", "Wolpertinger", "Zergling",
-- Turtle WoW / OctoWoW additions (community-known companions)
"Baby Blizzard Bear", "Baby Crocolisk", "Baby Murloc",
"Baby Ogre", "Baby Shark", "Baby Tallstrider", "Baby Turtle",
"Bat Cub", "Bloodsail Cannonball", "Blue Moth", "Bronze Whelpling",
"Captured Firefly", "Clockwork Rocket Bot", "Cobra Hatchling",
"Coral Whelpling", "Corrupt Whelpling", "Deviate Hatchling",
"Elekk Plushie", "Ethereal Soul-Trader", "Fel Whelpling",
"Forest Frog", "Fox Kit", "Ghost Cat", "Ghostly Skull",
"Gorilla Hatchling", "Grunty", "Hippogryph Hatchling",
"Ivory Raptor", "Jade Whelpling", "Lil' Alexstrasza",
"Lil' Deathwing", "Lil' K.T.", "Lil' Ragnaros", "Lil' Smoky",
"Lil' Tarecgosa", "Lil' Timmy", "Lil' Wickerman", "Lil' XT",
"Lucky Cricket", "Magical Crawdad", "Mechanical Chicken",
"Micro Sentry", "Mini Tyrael", "Moonkin Hatchling",
"Murky", "Naga Hatchling", "Netherwhelp", "Nightsaber Cub",
"Onyx Whelpling", "Pandaren Monk", "Peanut", "Perky Pug",
"Piglet's Collar", "Pink Elekk", "Plague Rat", "Polar Bear Cub",
"Purple Turtle", "Quilen Cub", "Raptor Hatchling", "Red Panda",
"Reef Wanderer", "Riding Turtle Hatchling", "Sand Kitten",
"Sandbox Tiger", "Sandstone Drake", "Sea Pony", "Shore Crab",
"Silithid Hatchling", "Snowball", "Snowy Rabbit",
"Spectral Tiger Cub", "Stormwind Rat", "Swamp Frog",
"Tiny Emerald Whelpling", "Tiny Sporebat", "Tricorne",
"Turkey", "Undercity Cockroach", "Wisp", "Withers",
"Ziggy the Sifaka",
-- OctoWoW Companions tab (as shown in the "P" menu)
"Green Steam Tonk", "Purple Steam Tonk", "Blue Steam Tonk",
"Red Steam Tonk", "Yellow Steam Tonk", "Steam Tonk",
"Steam Tonk Controller", "Control Console",
"Lulu", "Mr. Wiggles", "Pet Bombling", "Lifelike Mechanical Toad",
"Tranquil Mechanical Yeti", "Mechanical Squirrel",
"Green Kitten", "Black Kitten", "Orange Kitten", "Cornish Rex Cat",
"Bombay Cat", "Siamese Cat", "Silver Tabby Cat",
}
local RA_KNOWN_COMPANIONS_SET = {}
for _, n in ipairs(RA_KNOWN_COMPANIONS_LIST) do
RA_KNOWN_COMPANIONS_SET[string.lower(n)] = true
end
-- Substring patterns that identify a companion regardless of colour / prefix.
-- Any spellbook or summoned pet name containing one of these (case-insensitive)
-- is treated as a known companion. Covers the OctoWoW / Turtle families where
-- every colour variant shares the same base name (e.g. "Green Steam Tonk",
-- "Purple Steam Tonk", "White Steam Tonk" all match "steam tonk").
local RA_COMPANION_PATTERNS = {
"steam tonk", "kitten", "whelpling", "hatchling", "cub",
"prairie dog", "rabbit", "squirrel", "toad", "frog",
"cockroach", "cat carrier", "mechanical squirrel",
"lil'", "lil ", "mini ", "tiny ", "baby ",
"companion", "critter",
}
function RA_IsKnownCompanionName(name)
if type(name) ~= "string" or name == "" then return false end
local lower = string.lower(name)
if RA_KNOWN_COMPANIONS_SET[lower] == true then return true end
for _, pat in ipairs(RA_COMPANION_PATTERNS) do
if string.find(lower, pat, 1, true) then return true end
end
return false
end
function RA_AddKnownCompanion(name)
if type(name) == "string" and name ~= "" then
RA_KNOWN_COMPANIONS_SET[string.lower(name)] = true
end
end
-- Credit a companion by name into the same store the tooltip path uses so
-- the total petCount stays consistent no matter which detection path found it.
function RA_CreditCompanionByName(name)
if not RA_IsKnownCompanionName(name) then return end
if not RelationshipsAchievementsCharDB then return end
if not RelationshipsAchievementsCharDB.countedPets then
RelationshipsAchievementsCharDB.countedPets = {}
end
RelationshipsAchievementsCharDB.countedPets[name] = true
local n = 0
for _ in pairs(RelationshipsAchievementsCharDB.countedPets) do n = n + 1 end
local companionPets = 0
if RelationshipsAchievementsCharDB.countedCompanions then
for _ in pairs(RelationshipsAchievementsCharDB.countedCompanions) do
companionPets = companionPets + 1
end
end
local total = n
if companionPets > total then total = companionPets end
RelationshipsAchievements:UpdateStat("petCount", total)
if total >= 1 then RelationshipsAchievements:Unlock(1204) end
if total >= 10 then RelationshipsAchievements:Unlock(1205) end
end
local function TooltipText(index, book)
RA_ScanTip:ClearLines()
RA_ScanTip:SetSpell(index, book)
local text = ""
for line = 1, RA_ScanTip:NumLines() do
local fs = getglobal("RelationshipsAchievementsScanTipTextLeft"..line)
if fs then
local t = fs:GetText()
if t then text = text .. "\n" .. t end
end
end
return string.lower(text)
end
-- ============ Known mount names (Vanilla + TurtleWoW / OctoWoW) ============
-- Same rationale as the companion list: some private-server mounts (e.g. the
-- OctoWoW "Zebra") have tooltips that don't include the classic "mount speed"
-- / "rideable" phrasing, so tooltip scanning misses them. A name whitelist
-- guarantees credit whenever such a spell appears in the spellbook or is
-- summoned by the player.
local RA_KNOWN_MOUNTS_LIST = {
-- Horde racial ground mounts (Vanilla)
"Brown Wolf", "Dire Wolf", "Timber Wolf", "Red Wolf", "Winter Wolf",
"Frostwolf", "Black Wolf", "Arctic Wolf", "Swift Brown Wolf",
"Swift Timber Wolf", "Swift Gray Wolf", "Swift Frostwolf",
"Black Skeletal Horse", "Blue Skeletal Horse", "Brown Skeletal Horse",
"Red Skeletal Horse", "Green Skeletal Warhorse", "Purple Skeletal Warhorse",
"Red Skeletal Warhorse", "Grey Kodo", "Brown Kodo", "Green Kodo",
"Teal Kodo", "Great White Kodo", "Great Grey Kodo", "Great Brown Kodo",
"Great Green Kodo", "Ivory Raptor", "Emerald Raptor", "Turquoise Raptor",
"Violet Raptor", "Obsidian Raptor", "Swift Blue Raptor", "Swift Olive Raptor",
"Swift Orange Raptor", "Whistle of the Emerald Raptor",
"Whistle of the Ivory Raptor", "Whistle of the Turquoise Raptor",
"Whistle of the Violet Raptor", "Whistle of the Mottled Red Raptor",
-- Alliance racial ground mounts (Vanilla)
"Pinto", "Palomino", "Chestnut Mare", "Brown Horse", "White Stallion",
"Black Stallion", "Swift Brown Steed", "Swift Palomino", "Swift White Steed",
"Gray Ram", "Brown Ram", "White Ram", "Black Ram", "Frost Ram",
"Swift Gray Ram", "Swift Brown Ram", "Swift White Ram",
"Mechanostrider", "Blue Mechanostrider", "Green Mechanostrider",
"Red Mechanostrider", "White Mechanostrider", "Yellow Mechanostrider",
"Unpainted Mechanostrider", "Swift Green Mechanostrider",
"Swift White Mechanostrider", "Swift Yellow Mechanostrider",
"Striped Frostsaber", "Striped Nightsaber", "Spotted Frostsaber",
"Striped Dawnsaber", "Swift Frostsaber", "Swift Mistsaber",
"Swift Stormsaber", "Reins of the Striped Frostsaber",
"Reins of the Striped Nightsaber", "Reins of the Spotted Frostsaber",
-- Class mounts (Vanilla)
"Summon Warhorse", "Summon Charger", "Summon Felsteed", "Summon Dreadsteed",
"Warhorse", "Charger", "Felsteed", "Dreadsteed",
-- PvP / faction / reputation / rare mounts (Vanilla)
"Black Battlestrider", "Black War Kodo", "Black War Ram", "Black War Raptor",
"Black War Steed", "Black War Tiger", "Black War Wolf", "Horn of the Black War Wolf",
"Horn of the Black Wolf", "Horn of the Brown Wolf", "Horn of the Dire Wolf",
"Horn of the Red Wolf", "Horn of the Timber Wolf", "Horn of the Arctic Wolf",
"Horn of the Frostwolf", "Horn of the Swift Brown Wolf",
"Horn of the Swift Gray Wolf", "Horn of the Swift Timber Wolf",
"Rivendare's Deathcharger", "Deathcharger's Reins", "Reins of the Rivendare's Deathcharger",
"Winterspring Frostsaber", "Reins of the Winterspring Frostsaber",
"Swift Zulian Tiger", "Reins of the Swift Zulian Tiger",
"Swift Razzashi Raptor", "Reins of the Swift Razzashi Raptor",
"Qiraji Battle Tank", "Reins of the Blue Qiraji Battle Tank",
"Reins of the Green Qiraji Battle Tank", "Reins of the Red Qiraji Battle Tank",
"Reins of the Yellow Qiraji Battle Tank",
"Tawny Sabercat", "Reins of the Tawny Sabercat",
"Cenarion War Hippogryph", "Reins of the Cenarion War Hippogryph",
-- Turtle WoW / OctoWoW custom mounts (community-known)
"Zebra", "Reins of the Zebra", "Whistle of the Zebra",
"Zhevra", "Reins of the Zhevra",
"Silver Riding Turtle", "Reins of the Silver Riding Turtle",
"Riding Turtle", "Reins of the Riding Turtle",
"Sea Turtle", "Reins of the Sea Turtle",
"Brown Ostrich", "White Ostrich", "Black Ostrich", "Reins of the Ostrich",
"Swift Ostrich", "Reins of the Swift Ostrich",
"Camel", "Brown Camel", "Tan Camel", "White Camel", "Reins of the Camel",
"Swift Camel", "Reins of the Swift Camel",
"Reindeer", "Winter Reindeer", "Reins of the Reindeer",
"Great Elk", "Reins of the Great Elk",
"Moose", "Reins of the Moose", "Swift Moose",
"Boar", "War Boar", "Reins of the War Boar", "Swift War Boar",
"Bear Mount", "Riding Bear", "Reins of the Riding Bear",
"Swift Bear", "Polar Bear", "Reins of the Polar Bear",
"Cheetah", "Reins of the Cheetah", "Swift Cheetah",
"Lion", "White Lion", "Reins of the White Lion",
"Steam Tank Mount", "Steamscale", "Mechano-Hog",
"Goblin Trike", "Trike", "Reins of the Trike",
"Snow Leopard", "Reins of the Snow Leopard",
"Ochre Skeletal Warhorse", "Ancona Chicken Mount",
"Turtle Mount", "Great Sea Turtle",
"Black Panther", "Reins of the Black Panther",
"Giraffe", "Reins of the Giraffe",
"Hyena", "Reins of the Hyena",
"Ram of Ironforge", "Kodo of Thunder Bluff",
}
local RA_KNOWN_MOUNTS_SET = {}
for _, n in ipairs(RA_KNOWN_MOUNTS_LIST) do
RA_KNOWN_MOUNTS_SET[string.lower(n)] = true
end
function RA_IsKnownMountName(name)
if type(name) ~= "string" or name == "" then return false end
return RA_KNOWN_MOUNTS_SET[string.lower(name)] == true
end
function RA_AddKnownMount(name)
if type(name) == "string" and name ~= "" then
RA_KNOWN_MOUNTS_SET[string.lower(name)] = true
end
end
-- Persistent per-character store so name-detected mounts survive reloads and
-- merge with tooltip-detected ones without double counting.
function RA_CreditMountByName(name)
if not RA_IsKnownMountName(name) then return end
if not RelationshipsAchievementsCharDB then return end
if not RelationshipsAchievementsCharDB.countedMounts then
RelationshipsAchievementsCharDB.countedMounts = {}
end
RelationshipsAchievementsCharDB.countedMounts[string.lower(name)] = true
end
local function CountStoredMounts()
if not RelationshipsAchievementsCharDB
or not RelationshipsAchievementsCharDB.countedMounts then
return 0
end
local n = 0
for _ in pairs(RelationshipsAchievementsCharDB.countedMounts) do n = n + 1 end
return n
end
local function ScanSpellbook()
if not GetSpellName then return end
local book = BOOKTYPE_SPELL or "spell"
local mounts, pets = 0, 0
local hasEpicMount = false
local seen = {}
local i = 1
while true do
local name, rank = GetSpellName(i, book)
if not name then break end
local key = name .. "||" .. (rank or "")
if not seen[key] then
seen[key] = true
local tip = TooltipText(i, book)
-- Mounts: tooltip includes "mount speed" or "riding" descriptor.
-- We match both the classic "increases speed by X%" line and the
-- explicit "summons and dismisses a rideable" phrasing.
local isMount = string.find(tip, "mount speed", 1, true)
or string.find(tip, "rideable", 1, true)
or string.find(tip, "summons and dismisses a rideable", 1, true)
-- Non-combat pets: tooltip contains "right click to dismiss"
-- or the classic "companion" descriptor. Explicitly exclude
-- combat pets (hunter/warlock), which say "summons your pet".
-- Non-combat pets: several tooltip phrasings exist across
-- Vanilla / TurtleWoW / OctoWoW. Match any of them, then filter
-- out combat pets (hunter/warlock) which say "summons your pet".
local isPet = (
string.find(tip, "companion", 1, true)
or string.find(tip, "right click to dismiss", 1, true)
or string.find(tip, "small pet", 1, true)
or string.find(tip, "critter", 1, true)
or string.find(tip, "summons and dismisses a", 1, true)
or string.find(tip, "summons and dismisses your", 1, true)
or string.find(tip, "summon a small", 1, true)
or string.find(tip, "vanity pet", 1, true)
or string.find(tip, "non-combat pet", 1, true)
)
and not string.find(tip, "summons your pet", 1, true)
and not string.find(tip, "combat pet", 1, true)
and not isMount
if isMount then
mounts = mounts + 1
-- Epic ground mounts grant +100% speed in vanilla.
if string.find(tip, "100%", 1, true) or string.find(tip, "epic", 1, true) then
hasEpicMount = true
end
if RA_CreditMountByName then RA_CreditMountByName(name) end
elseif isPet then
pets = pets + 1
end
-- Name-based fast path: known vanilla + Turtle/Octo companions.
-- Ensures custom pets whose tooltips don't match keywords still count.
if (not isPet) and (not isMount)
and RA_IsKnownCompanionName and RA_IsKnownCompanionName(name) then
pets = pets + 1
if RA_CreditCompanionByName then RA_CreditCompanionByName(name) end
end
-- Name-based fast path: known vanilla + Turtle/Octo mounts.
-- Catches custom mounts (e.g. Zebra) whose tooltips omit the
-- classic "mount speed" / "rideable" keywords.
if (not isMount) and (not isPet)
and RA_IsKnownMountName and RA_IsKnownMountName(name) then
mounts = mounts + 1
if RA_CreditMountByName then RA_CreditMountByName(name) end
end
end
i = i + 1
end
-- Fallback: Riding skill unambiguously proves mount ownership even if
-- tooltip scanning missed a custom mount (Turtle/OctoWoW variants).
if GetNumSkillLines then
for s = 1, GetNumSkillLines() do
local skill, header, _, rank = GetSkillLineInfo(s)
if not header and skill == "Riding" and rank and rank > 0 then
if mounts < 1 then mounts = 1 end
if rank >= 150 then hasEpicMount = true end
end
end
end
-- Merge with the persistent name-detected mount store so counts never
-- regress if a mount was seen previously but is currently unavailable
-- from the spellbook scan (e.g. server API hiccup).
local storedMounts = CountStoredMounts()
if storedMounts > mounts then mounts = storedMounts end
RelationshipsAchievements:UpdateStat("mountCount", mounts)
RelationshipsAchievements:UpdateStat("petCount", pets)
if mounts >= 1 then RelationshipsAchievements:Unlock(211) end
if hasEpicMount and (UnitLevel("player") or 1) >= 60 then
RelationshipsAchievements:Unlock(212)
end
if pets >= 1 then RelationshipsAchievements:Unlock(1204) end
end
-- Expose so slash command / minimap can force a rescan.
RelationshipsAchievements_ScanSpellbook = ScanSpellbook
-- ============ Companion API scanning (OctoWoW / Turtle) ============
-- OctoWoW's Pet menu ("P") has a Companions tab powered by the backported
-- Companion API (GetNumCompanions/GetCompanionInfo/CallCompanion). This is
-- the authoritative source for non-combat pet ownership on those clients
-- and is far more reliable than tooltip scanning the spellbook.
local function EnsureCountedCompanions()
if not RelationshipsAchievementsCharDB then return nil end
if not RelationshipsAchievementsCharDB.countedCompanions then
RelationshipsAchievementsCharDB.countedCompanions = {}
end
return RelationshipsAchievementsCharDB.countedCompanions
end
local function CountTable(t)
local n = 0
if t then for _ in pairs(t) do n = n + 1 end end
return n
end
local function ScanCompanions()
if not GetNumCompanions then return end
local counted = EnsureCountedCompanions()
if not counted then return end
-- Different builds/servers register the companion tab under different
-- type strings. Try each so we always sync with the "P" menu Companions
-- tab regardless of which naming OctoWoW / Turtle uses.
local types = { "CRITTER", "COMPANION", "COMPANIONS", "PET", "MINIPET" }
for _, ctype in ipairs(types) do
local ok, num = pcall(GetNumCompanions, ctype)
if ok and num and num > 0 then
for i = 1, num do
local ok2, _, name = pcall(GetCompanionInfo, ctype, i)
if ok2 and name and name ~= "" then
counted[name] = true
else
counted[ctype.."_"..i] = true
end
end
end
end
local total = CountTable(counted)
-- Merge with tooltip-scanned pets so we always report the higher value.
local spellPets = (RelationshipsAchievementsCharDB.stats and RelationshipsAchievementsCharDB.stats.petCount) or 0
if total < spellPets then total = spellPets end
RelationshipsAchievements:UpdateStat("petCount", total)
if total >= 1 then RelationshipsAchievements:Unlock(1204) end
if total >= 10 then RelationshipsAchievements:Unlock(1205) end
end
RelationshipsAchievements_ScanCompanions = ScanCompanions
-- Backup path: hook CallCompanion so a summon always credits the pet, even
-- if the addon loaded before the Companion API populated.
if CallCompanion then
local _origCallCompanion = CallCompanion
CallCompanion = function(ctype, index)
_origCallCompanion(ctype, index)
if ctype == "CRITTER" then
local counted = EnsureCountedCompanions()
if counted then
local ok, _, name = pcall(GetCompanionInfo, "CRITTER", index)
local key = (ok and name and name ~= "") and name or ("companion_"..index)
counted[key] = true
local n = CountTable(counted)
RelationshipsAchievements:UpdateStat("petCount", n)
RelationshipsAchievements:Unlock(1204)
if n >= 10 then RelationshipsAchievements:Unlock(1205) end
end
end
end
end
-- Backup pet-count tracking: if a player casts a spell whose tooltip matches
-- pet criteria, ensure it counts. This catches Turtle/Octo custom companions
-- whose tooltips ScanSpellbook may not have visited yet.
local function EnsureCountedPets()
if not RelationshipsAchievementsCharDB then return nil end
if not RelationshipsAchievementsCharDB.countedPets then
RelationshipsAchievementsCharDB.countedPets = {}
end
return RelationshipsAchievementsCharDB.countedPets
end
local function TooltipTextByName(spellName)
if not spellName or spellName == "" then return "" end
RA_ScanTip:ClearLines()
-- SetSpell by index only; iterate spellbook once to find the name.
if not GetSpellName then return "" end
local book = BOOKTYPE_SPELL or "spell"
local i = 1
while true do
local name = GetSpellName(i, book)
if not name then break end
if name == spellName then
return TooltipText(i, book)
end
i = i + 1
end
return ""
end
local function CreditPetSpell(spellName)
if not spellName or spellName == "" then return end
local counted = EnsureCountedPets()
if not counted then return end
if counted[spellName] then return end
-- Known-companion fast path (no tooltip required).
if RA_IsKnownCompanionName and RA_IsKnownCompanionName(spellName) then
counted[spellName] = true
local n = 0
for _ in pairs(counted) do n = n + 1 end
local companionPets = 0
if RelationshipsAchievementsCharDB and RelationshipsAchievementsCharDB.countedCompanions then
for _ in pairs(RelationshipsAchievementsCharDB.countedCompanions) do
companionPets = companionPets + 1
end
end
local total = n
if companionPets > total then total = companionPets end
RelationshipsAchievements:UpdateStat("petCount", total)
RelationshipsAchievements:Unlock(1204)
if total >= 10 then RelationshipsAchievements:Unlock(1205) end
return
end
local tip = TooltipTextByName(spellName)
if tip == "" then return end
local isPet = (
string.find(tip, "companion", 1, true)
or string.find(tip, "right click to dismiss", 1, true)
or string.find(tip, "small pet", 1, true)
or string.find(tip, "critter", 1, true)
or string.find(tip, "summons and dismisses a", 1, true)
or string.find(tip, "summons and dismisses your", 1, true)
or string.find(tip, "vanity pet", 1, true)
or string.find(tip, "non-combat pet", 1, true)
)
and not string.find(tip, "summons your pet", 1, true)
and not string.find(tip, "mount speed", 1, true)
and not string.find(tip, "rideable", 1, true)
if isPet then
counted[spellName] = true
local n = 0
for _ in pairs(counted) do n = n + 1 end
RelationshipsAchievements:UpdateStat("petCount", n)
RelationshipsAchievements:Unlock(1204)
end
end
-- Credit a mount cast by name. Mirrors CreditPetSpell but for mounts,
-- using the known-mount whitelist so custom Turtle/OctoWoW mounts (e.g.
-- "Zebra") count the moment the player summons them, even if the client's
-- spellbook scan hadn't picked them up yet.
local function CreditMountSpell(spellName)
if not spellName or spellName == "" then return end
if not RA_IsKnownMountName or not RA_IsKnownMountName(spellName) then return end
if not RelationshipsAchievementsCharDB then return end
if not RelationshipsAchievementsCharDB.countedMounts then
RelationshipsAchievementsCharDB.countedMounts = {}
end
local key = string.lower(spellName)
if RelationshipsAchievementsCharDB.countedMounts[key] then return end
RelationshipsAchievementsCharDB.countedMounts[key] = true
local n = 0
for _ in pairs(RelationshipsAchievementsCharDB.countedMounts) do n = n + 1 end
-- Never regress the stat: prefer whichever count is higher.
local prev = 0
if RelationshipsAchievementsCharDB.stats
and RelationshipsAchievementsCharDB.stats.mountCount then
prev = RelationshipsAchievementsCharDB.stats.mountCount
end
if n < prev then n = prev end
RelationshipsAchievements:UpdateStat("mountCount", n)
RelationshipsAchievements:Unlock(211)
end
local _origCastSpellByName = CastSpellByName
if _origCastSpellByName then CastSpellByName = function(name, onSelf)
_origCastSpellByName(name, onSelf)
CreditPetSpell(name)
CreditMountSpell(name)
end end
local _origCastSpell = CastSpell
if _origCastSpell then CastSpell = function(spellId, bookType)
_origCastSpell(spellId, bookType)
if GetSpellName then
local n = GetSpellName(spellId, bookType or BOOKTYPE_SPELL or "spell")
CreditPetSpell(n)
CreditMountSpell(n)
end
end end
local _origUseAction = UseAction
if _origUseAction then UseAction = function(slot, checkCursor, onSelf)
_origUseAction(slot, checkCursor, onSelf)
if GetActionText then
local txt = GetActionText(slot)
if txt then
CreditPetSpell(txt)
CreditMountSpell(txt)
end
end
end end
function RelationshipsAchievements_ScanCharacter()
RelationshipsAchievements:Init()
local lvl = UnitLevel("player") or 1
if lvl >= 5 then RelationshipsAchievements:Unlock(216) end
if lvl >= 10 then RelationshipsAchievements:Unlock(201) end
if lvl >= 15 then RelationshipsAchievements:Unlock(218) end
if lvl >= 20 then RelationshipsAchievements:Unlock(202) end
if lvl >= 25 then RelationshipsAchievements:Unlock(219) end
if lvl >= 30 then RelationshipsAchievements:Unlock(203) end
if lvl >= 35 then RelationshipsAchievements:Unlock(220) end
if lvl >= 40 then RelationshipsAchievements:Unlock(204) end
if lvl >= 45 then RelationshipsAchievements:Unlock(221) end
if lvl >= 50 then RelationshipsAchievements:Unlock(205) end
if lvl >= 55 then RelationshipsAchievements:Unlock(222) end
if lvl >= 60 then RelationshipsAchievements:Unlock(206) end
-- Custom races and OctoWoW level milestone.
local race = UnitRace("player")
if race == "High Elf" or race == "HighElf" then RelationshipsAchievements:Unlock(1302) end
if race == "Goblin" then RelationshipsAchievements:Unlock(1303) end
if lvl >= 60 then RelationshipsAchievements:Unlock(1307) end
-- Class-at-60 achievements
if lvl >= 60 then
local _, class = UnitClass("player")
if class == "WARRIOR" then RelationshipsAchievements:Unlock(1101)
elseif class == "PALADIN" then RelationshipsAchievements:Unlock(1102)
elseif class == "HUNTER" then RelationshipsAchievements:Unlock(1103)
elseif class == "ROGUE" then RelationshipsAchievements:Unlock(1104)
elseif class == "PRIEST" then RelationshipsAchievements:Unlock(1105)
elseif class == "SHAMAN" then RelationshipsAchievements:Unlock(1106)
elseif class == "MAGE" then RelationshipsAchievements:Unlock(1107)
elseif class == "WARLOCK" then RelationshipsAchievements:Unlock(1108)
elseif class == "DRUID" then RelationshipsAchievements:Unlock(1109)
end
end
if GetGuildInfo and GetGuildInfo("player") then
RelationshipsAchievements:Unlock(103)
if GetNumGuildMembers and GetNumGuildMembers() >= 10 then RelationshipsAchievements:Unlock(1703) end
end
if GetNumPartyMembers and GetNumPartyMembers() > 0 then
RelationshipsAchievements:Unlock(105)
end
if GetNumFriends and GetNumFriends() > 0 then
RelationshipsAchievements:Unlock(102)
RelationshipsAchievements:UpdateStat("friendCount", GetNumFriends())
end
if GetInventoryItemLink and GetInventoryItemLink("player", 19) then
RelationshipsAchievements:Unlock(109)
end
-- Gold-in-bag achievements (retroactive on scan)
if GetMoney then
local copper = GetMoney() or 0
if copper >= 100 then RelationshipsAchievements:Unlock(1901) end -- 1 silver
if copper >= 10000 then RelationshipsAchievements:Unlock(1902) end -- 1 gold
if copper >= 5000000 then RelationshipsAchievements:Unlock(223) end -- 500g
if copper >= 10000000 then RelationshipsAchievements:Unlock(1903) end -- 1000g
if copper >= 50000000 then RelationshipsAchievements:Unlock(224) end -- 5000g
if copper >= 100000000 then RelationshipsAchievements:Unlock(1904) end -- 10000g
end
local zc = 0
for _ in pairs(RelationshipsAchievementsCharDB.zones) do zc = zc + 1 end
RelationshipsAchievements:UpdateStat("zoneCount", zc)
if GetNumSkillLines then
local highestProf = 0
local highestWeapon = 0
for i = 1, GetNumSkillLines() do
local name, isHeader, _, rank = GetSkillLineInfo(i)
if not isHeader and rank and rank > 0 then
if name == "Swords" or name == "Two-Handed Swords"
or name == "Axes" or name == "Two-Handed Axes"
or name == "Maces" or name == "Two-Handed Maces"
or name == "Daggers" or name == "Fist Weapons"
or name == "Polearms" or name == "Staves"
or name == "Bows" or name == "Crossbows" or name == "Guns"
or name == "Wands" or name == "Thrown" then
if rank > highestWeapon then highestWeapon = rank end
elseif name == "Cooking" then
RelationshipsAchievements:UpdateStat("cookingSkill", rank)
elseif name == "Fishing" then
RelationshipsAchievements:UpdateStat("fishingSkill", rank)
elseif name == "First Aid" then
if rank >= 300 then RelationshipsAchievements:Unlock(707) end
else
if rank > highestProf then highestProf = rank end
if rank >= 300 then
if name == "Alchemy" then RelationshipsAchievements:Unlock(708) end
if name == "Blacksmithing" then RelationshipsAchievements:Unlock(709) end
if name == "Enchanting" then RelationshipsAchievements:Unlock(710) end
if name == "Engineering" then RelationshipsAchievements:Unlock(711) end
if name == "Herbalism" then RelationshipsAchievements:Unlock(712) end
if name == "Leatherworking"then RelationshipsAchievements:Unlock(713) end
if name == "Mining" then RelationshipsAchievements:Unlock(714) end
if name == "Skinning" then RelationshipsAchievements:Unlock(715) end
if name == "Tailoring" then RelationshipsAchievements:Unlock(716) end
end
end
end
end
RelationshipsAchievements:UpdateStat("profSkill", highestProf)
RelationshipsAchievements:UpdateStat("weaponSkill", highestWeapon)
end
if GetNumFactions then
local exalted = 0
local hasFriendly, hasHonored, hasRevered, hasExalted = false, false, false, false
for i = 1, GetNumFactions() do
local name, _, standing = GetFactionInfo(i)
if standing then
if standing >= 5 then hasFriendly = true end
if standing >= 6 then hasHonored = true end
if standing >= 7 then hasRevered = true end
if standing >= 8 then
hasExalted = true
exalted = exalted + 1
if name and RelationshipsAchievements_FactionMap[name] then
RelationshipsAchievements:Unlock(RelationshipsAchievements_FactionMap[name])
end
RelationshipsAchievementsCharDB.factionsExalted[name or "?"] = true
end
end
end
if hasFriendly then RelationshipsAchievements:Unlock(801) end
if hasHonored then RelationshipsAchievements:Unlock(802) end
if hasRevered then RelationshipsAchievements:Unlock(803) end
if hasExalted then RelationshipsAchievements:Unlock(804) end
RelationshipsAchievements:UpdateStat("exaltedCount", exalted)
end
ScanPvPRank()
ScanPvPKills()
ScanTalents()
ScanEquipmentAndBags()
ScanSpellbook()
if RelationshipsAchievements_ScanCompanions then
RelationshipsAchievements_ScanCompanions()
end
RelationshipsAchievements_RefreshUI = RelationshipsAchievements_RefreshUI or function() end
if RelationshipsAchievementFrame and RelationshipsAchievementFrame:IsVisible() then
RelationshipsAchievements_RefreshUI()
end
end
local function BindHotkey()
if not SetBinding then return end
local existing = GetBindingAction and GetBindingAction("Y")
if existing and existing ~= "" and existing ~= "TOGGLERELATIONSHIPSACHIEVEMENT" then
return
end
SetBinding("Y", "TOGGLERELATIONSHIPSACHIEVEMENT")
end
-- ============ SendChatMessage hook (Chatty achievement) ============
-- Wrapping SendChatMessage is more reliable than reading CHAT_MSG_SAY back,
-- because whispers, guild chat, and channels each fire different events and
-- some don't echo the sender field consistently on Turtle / Vanilla.
local _origSendChatMessage = SendChatMessage
if _origSendChatMessage then SendChatMessage = function(msg, chatType, language, target)
_origSendChatMessage(msg, chatType, language, target)
if msg and msg ~= "" and RelationshipsAchievements and RelationshipsAchievements.AddStat then
RelationshipsAchievements:AddStat("chatCount", 1)
end
end end
local _origSendMail = SendMail
if _origSendMail then SendMail = function(name, subject, body)
_origSendMail(name, subject, body)
RelationshipsAchievements:Unlock(110)
end end
-- Auction House API is provided by Blizzard_AuctionUI, which is Load-on-Demand
-- in 1.12. Hooking at file-load time silently no-ops because the globals are
-- still nil. HookAuctionHouse() runs again on ADDON_LOADED "Blizzard_AuctionUI"
-- so the hooks attach as soon as the AH frame is opened for the first time.
local auctionHooked = false
local function HookAuctionHouse()
if auctionHooked then return end
-- Vanilla 1.12 uses StartAuction(minBid, buyoutPrice, runTime) to list the
-- item currently in the auction sell slot. PostAuction is a TBC+ API and
-- does not exist on Turtle/Octowow, which is why listings were never
-- being counted. Hook both so the addon keeps working if Turtle ever
-- back-ports PostAuction.
if not StartAuction and not PostAuction and not BuyoutAuction and not PlaceAuctionBid then return end
auctionHooked = true
if StartAuction then
local _orig = StartAuction
StartAuction = function(minBid, buyoutPrice, runTime)
_orig(minBid, buyoutPrice, runTime)
-- GetAuctionSellItemInfo returns (name, texture, count, ...); count is the stack size.
local count = 1
if GetAuctionSellItemInfo then
local _, _, c = GetAuctionSellItemInfo()
if c and c > 0 then count = c end
end
RelationshipsAchievements:AddStat("auctionsListed", 1)
-- (We count one auction per listing action; `count` is stack size, not # of auctions.)
local _ = count
end
end
if PostAuction then
local _orig = PostAuction
PostAuction = function(minBid, buyoutPrice, runTime, stackSize, numStacks)
_orig(minBid, buyoutPrice, runTime, stackSize, numStacks)
RelationshipsAchievements:AddStat("auctionsListed", numStacks or 1)
end
end
if BuyoutAuction then
local _orig = BuyoutAuction
BuyoutAuction = function(index)
_orig(index)
RelationshipsAchievements:Unlock(107)
RelationshipsAchievements:AddStat("auctionsWon", 1)
end
end
-- Many AH addons (and the default UI in some 1.12 builds) route the
-- Buyout button through PlaceAuctionBid(list, index, bid) with bid ==
-- buyout price. Hook it too so wins are always counted.
if PlaceAuctionBid then
local _orig = PlaceAuctionBid
PlaceAuctionBid = function(listType, index, bid)
_orig(listType, index, bid)
local _, _, _, _, _, _, _, _, buyout = GetAuctionItemInfo(listType, index)
if buyout and bid and bid >= buyout and buyout > 0 then
RelationshipsAchievements:Unlock(107)
RelationshipsAchievements:AddStat("auctionsWon", 1)
end
end
end
end
HookAuctionHouse() -- attempt immediately in case the UI is already loaded.
-- ============ Vendor sales tracking ============
-- The merchant window is always the default UI; hooks are safe to install at
-- file-load time. Two vectors cover every sell path:
-- * UseContainerItem while MerchantFrame is visible = shift-click / right-
-- click sell from the bag.
-- * Drag-and-drop onto the merchant → PickupContainerItem then a click on
-- the merchant, which resolves via UseContainerItem too, so one hook
-- catches both cases.
local function MerchantOpen()
return MerchantFrame and MerchantFrame:IsVisible()
end
local _origUseContainerItem = UseContainerItem
if _origUseContainerItem then UseContainerItem = function(bag, slot, target)
local sellable = false
if MerchantOpen() and GetContainerItemLink then
local link = GetContainerItemLink(bag, slot)
-- Skip if slot is empty or item is bound-cannot-sell? A no-op sell is
-- harmless — the vendor simply refuses — so we count only when the
-- API had something to act on.
if link then sellable = true end
end
_origUseContainerItem(bag, slot, target)
if sellable then
RelationshipsAchievements:AddStat("vendorSales", 1)
end
end end
local _origStaticPopupShow = StaticPopup_Show
if _origStaticPopupShow then StaticPopup_Show = function(which, text_arg1, text_arg2, data)
local popup = _origStaticPopupShow(which, text_arg1, text_arg2, data)
if which == "CONFIRM_BINDER" then RelationshipsAchievementsCharDB.pendingInnBind = true end
return popup
end end
-- ============ DoEmote hook (Say Hello / Dance Off) ============
-- Hooking DoEmote is the cleanest way: the token is stable ("wave", "dance")
-- across all locales, unlike the CHAT_MSG_TEXT_EMOTE text which is localized.
local _origDoEmote = DoEmote
if _origDoEmote then
DoEmote = function(token, target)
_origDoEmote(token, target)
if not token then return end
local t = string.lower(token)
if t == "wave" then
RelationshipsAchievements:Unlock(1701)
elseif t == "dance" then
RelationshipsAchievements:Unlock(1702)
end
end
end
local loginScanDone = false
local function OnLogin()
RelationshipsAchievements:Init()
RelationshipsAchievements:Unlock(101)
RelationshipsAchievements:Unlock(1301)
BindHotkey()
if not loginScanDone then
loginScanDone = true
local scanner = CreateFrame("Frame")
local acc = 0
scanner:SetScript("OnUpdate", function()
acc = acc + (arg1 or 0)
if acc >= 2 then
RelationshipsAchievements_ScanCharacter()
if ShowFriends then ShowFriends() end
if GuildRoster then GuildRoster() end
this:SetScript("OnUpdate", nil)
end
end)
end
end
local function OnLevelUp()
RelationshipsAchievements_ScanCharacter()
end
local function OnQuestComplete()
-- QUEST_COMPLETE is the reliable 1.12 turn-in event. Custom clients that
-- also emit QUEST_TURNED_IN are de-duplicated by time stamp below.
local now = time()
if RelationshipsAchievementsCharDB.lastQuestCredit == now then return end
RelationshipsAchievementsCharDB.lastQuestCredit = now
RelationshipsAchievements:AddStat("questCount", 1)
local title = GetTitleText and GetTitleText() or ""
local low = string.lower(title or "")
if string.find(low, "attunement to the core") then RelationshipsAchievements:Unlock(683) end
if string.find(low, "blackhand's command") then RelationshipsAchievements:Unlock(682) end
if string.find(low, "onyxia") or string.find(low, "great masquerade") then RelationshipsAchievements:Unlock(684) end
if string.find(low, "head of onyxia") or string.find(low, "victory for the alliance") or string.find(low, "victory for the horde") then RelationshipsAchievements:Unlock(2104) end
if string.find(low, "silithyst") then RelationshipsAchievements:AddStat("silithystTurnIns", 1) end
if string.find(low, "class") then RelationshipsAchievements:Unlock(312) end
end
local function OnZoneChanged()
local zone = GetRealZoneText() or GetZoneText()
if not zone or zone == "" then return end
if RelationshipsAchievements_CityMap and RelationshipsAchievements_CityMap[zone] then
RelationshipsAchievements:Unlock(RelationshipsAchievements_CityMap[zone])
RelationshipsAchievementsCharDB.cities = RelationshipsAchievementsCharDB.cities or {}
if not RelationshipsAchievementsCharDB.cities[zone] then
RelationshipsAchievementsCharDB.cities[zone] = true
local a, h = 0, 0
for c in pairs(RelationshipsAchievementsCharDB.cities) do
if RelationshipsAchievements_AllianceCities[c] then a = a + 1 end
if RelationshipsAchievements_HordeCities[c] then h = h + 1 end
end
RelationshipsAchievements:UpdateStat("allianceCitiesVisited", a)
RelationshipsAchievements:UpdateStat("hordeCitiesVisited", h)
if a >= 3 then RelationshipsAchievements:Unlock(1807) end
if h >= 3 then RelationshipsAchievements:Unlock(1808) end
end
end
local lowZone = string.lower(zone)
if string.find(lowZone, "karazhan crypt") then RelationshipsAchievements:Unlock(1305) end
if string.find(lowZone, "emerald sanctum") then RelationshipsAchievements:Unlock(1308) end
if not RelationshipsAchievementsCharDB.zones[zone] then
RelationshipsAchievementsCharDB.zones[zone] = true
local id = RelationshipsAchievements_ZoneMap[zone]
if id then RelationshipsAchievements:Unlock(id) end
local count = 0
for _ in pairs(RelationshipsAchievementsCharDB.zones) do count = count + 1 end
RelationshipsAchievements:UpdateStat("zoneCount", count)
end
end
local function OnHonorGain(msg)
-- Objective/bonus honor uses this event too, so never increment blindly.
-- Lifetime stats give the authoritative HK total and cannot double-count.
if GetPVPLifetimeStats then
local before = RelationshipsAchievements:GetStat("honorKills")
ScanPvPKills()
local after = RelationshipsAchievements:GetStat("honorKills")
if after > before then
RelationshipsAchievements:Unlock(501)
RelationshipsAchievements:UpdateStat("pvpKills", after)
end
elseif msg and string.find(string.lower(msg), "honorable kill") then
RelationshipsAchievements:Unlock(501)
RelationshipsAchievements:AddStat("honorKills", 1)
RelationshipsAchievements:AddStat("pvpKills", 1)
end
end
local function OnFactionChange(msg)
if not msg then return end
-- Recompute from the faction table. Incrementing from chat inflated the
-- count when duplicate messages fired and could award 5/10 Exalted early.
RelationshipsAchievements_ScanCharacter()
end
local function OnSkillLinesChanged()
RelationshipsAchievements_ScanCharacter()
end
local function OnLootMessage(msg)
if not msg then return end
local clean = string.gsub(msg, ",", "")
local _, _, g = string.find(clean, "(%d+) Gold")
if g then
RelationshipsAchievements:AddStat("goldLooted", tonumber(g))
end
local low = string.lower(msg)
if string.find(low, "fish") or string.find(low, "snapper") or string.find(low, "trout")
or string.find(low, "salmon") or string.find(low, "catfish") or string.find(low, "eel") then
RelationshipsAchievements:Unlock(1401)
RelationshipsAchievements:AddStat("fishCount", 1)
end
if string.find(low, "you create") and (string.find(low, "food") or string.find(low, "meal")
or string.find(low, "roast") or string.find(low, "stew")) then RelationshipsAchievements:Unlock(1501) end
end
local function OnEnemyDeath(msg)
if not msg then return end
if string.find(msg, "You have slain") or string.find(msg, "slain by you") then
RelationshipsAchievements:AddStat("killCount", 1)
end
local bossMap = RelationshipsAchievements_BossMap or {}
for boss, info in pairs(bossMap) do
if string.find(msg, boss, 1, true) then
RelationshipsAchievements:Unlock(info.id)
local seen = info.raid and RelationshipsAchievementsCharDB.raidBosses or RelationshipsAchievementsCharDB.dungeonBosses
if not seen[boss] then
seen[boss] = true
RelationshipsAchievements:AddStat(info.raid and "raidsCleared" or "dungeonsCleared", 1)
end
end
end
end
-- Match Turtle/Octo /played extension lines that expose the character's
-- completed-quest total. Multiple phrasings observed across custom builds.
local function TryParseQuestCount(msg)
if not msg then return end
local patterns = {
"[Qq]uests? [Cc]ompleted:?%s*(%d+)",
"[Cc]ompleted [Qq]uests?:?%s*(%d+)",
"[Tt]otal [Qq]uests?:?%s*(%d+)",
"[Qq]uests?[^%d]-(%d+)%s*completed",
}
for i = 1, table.getn(patterns) do
local _, _, num = string.find(msg, patterns[i])
if num then
local n = tonumber(num)
if n and n > 0 then
RelationshipsAchievements:UpdateStat("questCount", n)
return true
end
end
end
end
local function OnSystemMessage(msg)
if not msg then return end
TryParseQuestCount(msg)
local _, _, winner = string.find(msg, "^(.-) has defeated ")
if winner and winner == UnitName("player") then
RelationshipsAchievements:AddStat("duelWins", 1)
end
if string.find(msg, "home is now") or string.find(msg, "bound to") then
RelationshipsAchievements:Unlock(108)
RelationshipsAchievementsCharDB.pendingInnBind = nil
end
local low = string.lower(msg)
if string.find(low, "learned a new") or string.find(low, "you have learned") then RelationshipsAchievements:Unlock(1110) end
if string.find(low, "darkmoon faire") then RelationshipsAchievements:Unlock(911) end
if string.find(low, "trick or treat") then RelationshipsAchievements:Unlock(903) end
if string.find(low, "lunar festival") then RelationshipsAchievements:Unlock(909) end
if string.find(low, "winter veil") then RelationshipsAchievements:Unlock(901) end
-- Battleground victories (system messages contain "Warsong Gulch", etc.)
if string.find(msg, "[Vv]ictory") or string.find(msg, "wins!") then
if string.find(msg, "Warsong Gulch") then
RelationshipsAchievements:Unlock(505)
RelationshipsAchievements:AddStat("wsgWins", 1)
RelationshipsAchievements:AddStat("bgWins", 1)
elseif string.find(msg, "Alterac Valley") then
RelationshipsAchievements:Unlock(506)
RelationshipsAchievements:AddStat("avWins", 1)
RelationshipsAchievements:AddStat("bgWins", 1)
elseif string.find(msg, "Arathi Basin") then
RelationshipsAchievements:Unlock(507)
RelationshipsAchievements:AddStat("abWins", 1)
RelationshipsAchievements:AddStat("bgWins", 1)
end
end
end
-- Emote handler: catch /wave and /dance in every locale by matching the
-- token via DoEmote hook AND as a fallback matching the English text here.
local function OnTextEmote(msg, sender)
if not msg then return end
local me = UnitName("player")
-- vanilla emote text always begins with "You " when it's the local player,
-- or the player's name otherwise.
local mine = (sender and sender == me) or string.find(msg, "^You ")
if not mine then return end
if string.find(string.lower(msg), "wave") then
RelationshipsAchievements:Unlock(1701)
end
if string.find(string.lower(msg), "dance") then
RelationshipsAchievements:Unlock(1702)
end
end
frame:SetScript("OnEvent", function()
if event == "PLAYER_LOGIN" then
OnLogin()
elseif event == "PLAYER_ENTERING_WORLD" then
RelationshipsAchievements:Init()
BindHotkey()
OnZoneChanged()
ScanPvPRank()
elseif event == "PLAYER_LEVEL_UP" then
OnLevelUp()
elseif event == "PLAYER_DEAD" or event == "PLAYER_UNGHOST" or event == "PLAYER_ALIVE" then
if event == "PLAYER_DEAD" then
RelationshipsAchievements:Unlock(207)
RelationshipsAchievements:AddStat("deaths", 1)
end
elseif event == "QUEST_COMPLETE" or event == "QUEST_TURNED_IN" then
OnQuestComplete()
elseif event == "ZONE_CHANGED_NEW_AREA" or event == "ZONE_CHANGED" or event == "ZONE_CHANGED_INDOORS" then
OnZoneChanged()
elseif event == "CHAT_MSG_COMBAT_HONOR_GAIN" then
OnHonorGain(arg1)
elseif event == "CHAT_MSG_COMBAT_FACTION_CHANGE" then
OnFactionChange(arg1)
elseif event == "SKILL_LINES_CHANGED" then
OnSkillLinesChanged()
elseif event == "CHAT_MSG_LOOT" or event == "CHAT_MSG_MONEY" then
OnLootMessage(arg1)
elseif event == "CHAT_MSG_COMBAT_HOSTILE_DEATH" or event == "CHAT_MSG_COMBAT_XP_GAIN" then
OnEnemyDeath(arg1)
elseif event == "CHAT_MSG_SYSTEM" then
OnSystemMessage(arg1)
elseif event == "CHAT_MSG_TEXT_EMOTE" or event == "CHAT_MSG_EMOTE" then
OnTextEmote(arg1, arg2)
elseif event == "PLAYER_PVP_RANK_CHANGED"
or event == "HONOR_CURRENCY_UPDATE"
or event == "PLAYER_PVPKILLS_CHANGED" or event == "PLAYER_PVP_KILLS_CHANGED" then
ScanPvPRank()
ScanPvPKills()
elseif event == "HEARTHSTONE_BOUND" then
RelationshipsAchievements:Unlock(108)
RelationshipsAchievementsCharDB.pendingInnBind = nil
elseif event == "BANKFRAME_OPENED" then
RelationshipsAchievements:Unlock(106)
elseif event == "TRAINER_SHOW" then
RelationshipsAchievements:Unlock(111)
elseif event == "MAIL_SEND_SUCCESS" then
RelationshipsAchievements:Unlock(110)
elseif event == "PLAYER_EQUIPMENT_CHANGED"
or event == "UNIT_INVENTORY_CHANGED"
or event == "BAG_UPDATE" then
if event == "UNIT_INVENTORY_CHANGED" and arg1 ~= "player" then return end
ScanEquipmentAndBags()
elseif event == "ADDON_LOADED" then
if arg1 == "Blizzard_AuctionUI" then HookAuctionHouse() end
elseif event == "MERCHANT_SHOW" then
-- Rescan equipment when opening a vendor — buying gear or repairs
-- often coincides with equipping the item immediately after.
ScanEquipmentAndBags()
ScanSpellbook()
elseif event == "CHARACTER_POINTS_CHANGED" then
ScanTalentChange()
elseif event == "SPELLS_CHANGED" then
ScanSpellbook()
elseif event == "PLAYER_MONEY" then
if GetMoney then
local copper = GetMoney() or 0
if copper >= 100 then RelationshipsAchievements:Unlock(1901) end
if copper >= 10000 then RelationshipsAchievements:Unlock(1902) end
if copper >= 5000000 then RelationshipsAchievements:Unlock(223) end
if copper >= 10000000 then RelationshipsAchievements:Unlock(1903) end
if copper >= 50000000 then RelationshipsAchievements:Unlock(224) end
if copper >= 100000000 then RelationshipsAchievements:Unlock(1904) end
end
elseif event == "UNIT_HEALTH" and arg1 == "player" then
local maxHealth = UnitHealthMax("player") or 0
if maxHealth > 0 and UnitHealth("player") > 0 and UnitHealth("player") * 10 < maxHealth then
RelationshipsAchievements:Unlock(1605)
end
elseif event == "UPDATE_FACTION" then
RelationshipsAchievements_ScanCharacter()
elseif event == "PARTY_MEMBERS_CHANGED" then
if GetNumPartyMembers and GetNumPartyMembers() > 0 then
RelationshipsAchievements:Unlock(105)
end
elseif event == "FRIENDLIST_UPDATE" then
if GetNumFriends and GetNumFriends() > 0 then
RelationshipsAchievements:Unlock(102)
RelationshipsAchievements:UpdateStat("friendCount", GetNumFriends())
end
elseif event == "GUILD_ROSTER_UPDATE" then
if GetGuildInfo and GetGuildInfo("player") then
RelationshipsAchievements:Unlock(103)
if GetNumGuildMembers and GetNumGuildMembers() >= 10 then RelationshipsAchievements:Unlock(1703) end
end
elseif event == "TIME_PLAYED_MSG" then
-- Some /played variants (Turtle/Octo) also emit "Quests completed: N"
-- as separate CHAT_MSG_SYSTEM lines around this event. Rescan the
-- spellbook too so any pets learned since last scan are counted.
if RelationshipsAchievements_ScanSpellbook then
RelationshipsAchievements_ScanSpellbook()
end
elseif event == "UNIT_PET" or event == "UNIT_MODEL_CHANGED" then
-- A pet/companion just spawned or despawned. Rescan the spellbook so
-- newly-learned companion spells count immediately.
if arg1 == "player" and RelationshipsAchievements_ScanSpellbook then
RelationshipsAchievements_ScanSpellbook()
if RelationshipsAchievements_ScanCompanions then
RelationshipsAchievements_ScanCompanions()
end
-- Name-based fallback: if the summoned pet's unit name matches a
-- known companion, credit it directly.
if UnitName and RA_CreditCompanionByName then
local petName = UnitName("pet")
if petName then RA_CreditCompanionByName(petName) end
end
end
elseif event == "COMPANION_LEARNED" or event == "COMPANION_UPDATE"
or event == "COMPANION_UNLEARNED" or event == "PET_UI_UPDATE" then
if RelationshipsAchievements_ScanCompanions then
RelationshipsAchievements_ScanCompanions()
end
elseif event == "LEARNED_SPELL_IN_TAB" then
if RelationshipsAchievements_ScanSpellbook then
RelationshipsAchievements_ScanSpellbook()
end
if RelationshipsAchievements_ScanCompanions then
RelationshipsAchievements_ScanCompanions()
end
end
end)