GuildRecipesOcto = GuildRecipesOcto or {} ---@class GuildRecipesOcto local M = GuildRecipesOcto local QUALITY_COLORS = { [ "ff9d9d9d" ] = 0, -- poor (gray) [ "ffffffff" ] = 1, -- common (white) [ "ff1eff00" ] = 2, -- uncommon (green) [ "ff0070dd" ] = 3, -- rare (blue) [ "ffa335ee" ] = 4, -- epic (purple) [ "ffff8000" ] = 5, -- legendary (orange) } M.TRADE_SKILL_LOCALIZATION = { Alchemy = { enUS = "Alchemy", deDE = "Alchimie", frFR = "Alchimie", esES = "Alquimia", koKR = "연금술", zhCN = "炼金术", zhTW = "鍊金術", }, Blacksmithing = { enUS = "Blacksmithing", deDE = "Schmiedekunst", frFR = "Forge", esES = "Herrería", koKR = "대장기술", zhCN = "锻造", zhTW = "鍛造", }, Engineering = { enUS = "Engineering", deDE = "Ingenieurskunst", frFR = "Ingénierie", esES = "Ingeniería", koKR = "기계공학", zhCN = "工程学", zhTW = "工程學", }, Leatherworking = { enUS = "Leatherworking", deDE = "Lederverarbeitung", frFR = "Travail du cuir", esES = "Peletería", koKR = "가죽세공", zhCN = "制皮", zhTW = "製皮", }, Tailoring = { enUS = "Tailoring", deDE = "Schneiderei", frFR = "Couture", esES = "Sastrería", koKR = "재봉술", zhCN = "裁缝", zhTW = "裁縫", }, Enchanting = { enUS = "Enchanting", deDE = "Verzauberkunst", frFR = "Enchantement", esES = "Encantamiento", koKR = "마법부여", zhCN = "附魔", zhTW = "附魔", }, Jewelcrafting = { enUS = "Jewelcrafting", }, } function M.build_reverse_trade_map( locale ) local map = {} for internal, locs in pairs( M.TRADE_SKILL_LOCALIZATION ) do local localized = locs[ locale ] if localized then map[ localized ] = internal end end return map end ---@param item_link string ---@return integer? id ---@return string? name ---@return integer? quality function M.parse_item_link( item_link ) if type( item_link ) ~= "string" then return nil, nil, nil end local item_id = tonumber( string.match( item_link, ":(%d+)" ) ) local name = string.match( item_link, "|h%[(.-)%]|h" ) local quality local color = string.match( item_link, "|c(%x%x%x%x%x%x%x%x)" ) if color then quality = QUALITY_COLORS[ string.lower( color ) ] else local _, _, q = GetItemInfo( item_id ) quality = q or 1 end return item_id, name, quality end ---@param item Item ---@return string function M.get_item_name_colorized( item ) local color = ITEM_QUALITY_COLORS[ item.quality or 1 ] local hex = string.format( "%02x%02x%02x", color.r * 255, color.g * 255, color.b * 255 ) return string.format( "|cFF%s%s|r", hex, item.name ) end ---@param id integer ---@param name string ---@param quality integer ---@return string function M.make_item_link( id, name, quality ) local color = ITEM_QUALITY_COLORS[ quality or 1 ] local hex = string.format( "%02x%02x%02x", color.r * 255, color.g * 255, color.b * 255 ) return string.format( "|cFF%s|Hitem:%d:0:0:0|h[%s]|h|r", hex, id, name ) end function M.make_enchant_link( id, name ) return string.format( "|cFF%s|Henchant:%d|h[%s]|h|r", "71d5ff", id, name ) end function M.colorize_player_by_class( name, class ) if not class then return name end local color = RAID_CLASS_COLORS[ string.upper( class ) ] if not color.colorStr then color.colorStr = string.format( "ff%02x%02x%02x", color.r * 255, color.g * 255, color.b * 255 ) end return "|c" .. color.colorStr .. name .. "|r" end function M.clean_string( input ) input = string.gsub( input, "^%s+", "" ) input = string.gsub( input, "%s+$", "" ) input = string.gsub( input, "|c%x%x%x%x%x%x%x%x", "" ) input = string.gsub( input, "|r", "" ) return input end ---@param player_name string ---@return boolean ---@nodiscard function M.guild_member_online( player_name ) for i = 1, GetNumGuildMembers() do local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo( i ) if player_name == name and online == 1 then return true end end return false end ---@return table -- name -> online, built from a single pass over the guild roster ---@nodiscard -- Use this instead of calling guild_member_online() in a loop (e.g. once per -- synced player, or once per recipe row) -- each guild_member_online() call -- re-scans the entire roster, so doing that repeatedly is O(n*m) and can -- visibly stutter the game when triggered by a frequent event. function M.build_online_roster() local online = {} for i = 1, GetNumGuildMembers() do local name, _, _, _, _, _, _, _, is_online = GetGuildRosterInfo( i ) if name and is_online == 1 then online[ name ] = true end end return online end function M.get_server_timestamp() local server_hour, server_min = GetGameTime() local local_time = date( "*t" ) local t = { year = local_time.year, month = local_time.month, day = local_time.day, hour = server_hour, min = server_min, sec = 0, } local hour_diff = server_hour - local_time.hour if hour_diff <= -20 then t.day = t.day + 1 elseif hour_diff >= 20 then t.day = t.day - 1 end return time( t ) end ---@param message string ---@param short boolean? function M.info( message, short ) local tag = string.format( "|c%s%s|r", M.tagcolor, short and "GRO" or "GuildRecipesOcto" ) DEFAULT_CHAT_FRAME:AddMessage( string.format( "%s: %s", tag, message ) ) end ---@param message string function M.error( message ) local tag = string.format( "|c%s%s|r|cffff0000%s|r", M.tagcolor, "GR", "ERROR" ) DEFAULT_CHAT_FRAME:AddMessage( string.format( "%s: %s", tag, message ) ) end function M.debug( message ) if M.debug_enabled then M.info( message, true ) end end ---@param t string[] ---@return string ---@nodiscard function M.table_to_comma_separated( t ) local s = "" for _, v in pairs( t ) do if s ~= "" then s = s .. "," end s = s .. tostring( v ) end return s end ---@param s string ---@return string[] ---@nodiscard function M.comma_separated_to_table( s ) local t = {} for v in string.gmatch( s, "([^,]+)" ) do table.insert( t, v ) end return t end ---@param value string|number ---@param t table ---@param extract_field string? ---@nodiscard function M.find( value, t, extract_field ) if type( t ) ~= "table" or M.count( t ) == 0 then return nil end for i, v in pairs( t ) do local val = extract_field and v[ extract_field ] or v if val == value then return v, i end end return nil end ---@param t table ---@param field string? ---@return number function M.count( t, field ) local count = 0 for _, e in pairs( t ) do if field and e[ field ] and e[ field ] > 0 or not field then count = count + 1 end end return count end ---@param recipes table ---@param player string ---@return number function M.count_recipes( recipes, player ) local _, player_id = M.find( player, M.db.players ) local count = 0 if player_id and recipes then for _, recipe in pairs( recipes ) do if M.find( tostring( player_id ), M.comma_separated_to_table( recipe.p ) ) then count = count + 1 end end end return count end ---@param id integer ---@param ufunc function ---@param data table? ---@return nil function M.get_item_info( id, ufunc, data ) local function callback( item_id, cbfunc, _data ) local info = { GetItemInfo( item_id ) } cbfunc( { id = item_id, name = info[ 1 ], link = info[ 2 ], quality = info[ 3 ], texture = info[ 9 ], }, _data ) end if GetItemInfo( id ) then callback( id, ufunc, data ) return end if not M.item_info_frame then M.item_info_frame = CreateFrame( "Frame" ) end if not M.get_item_info_items then M.get_item_info_items = {} end table.insert( M.get_item_info_items, { id = id, ufunc = ufunc, data = data } ) if not M.item_info_frame:GetScript( "OnUpdate" ) then local elapsed = 0 local poll_interval = 0.1 M.item_info_frame:SetScript( "OnUpdate", function() local item_data = M.get_item_info_items[ 1 ] if not item_data then M.item_info_frame:SetScript( "OnUpdate", nil ) return end elapsed = elapsed + arg1 if elapsed < poll_interval then return end elapsed = 0 M.tooltip:SetOwner( WorldFrame, "ANCHOR_NONE" ) M.tooltip:SetHyperlink( "item:" .. item_data.id ) if GetItemInfo( item_data.id ) then callback( item_data.id, item_data.ufunc, item_data.data ) table.remove( M.get_item_info_items, 1 ) end end ) end end ---@param link string -- item link or spell link ---@param callback function -- function(lines: string[]|nil) end ---@param max_attempts number|nil -- retry limit (default 5) ---@param delay number|nil function M.scan_tooltip( link, callback, max_attempts, delay ) ---@class TooltipScan ---@field link string ---@field callback fun(lines: string[]|nil) ---@field max_attempts number ---@field delay number ---@field attempt number ---@field elapsed number ---@field initialized boolean ---@type TooltipScan | nil local current_scan = nil local scan_queue = {} local start_next_scan local function process_scan() if not current_scan then M.tooltip_scan_frame:SetScript( "OnUpdate", nil ) return end current_scan.elapsed = current_scan.elapsed + arg1 if current_scan.elapsed < current_scan.delay then return end current_scan.elapsed = 0 current_scan.attempt = current_scan.attempt + 1 M.tooltip:SetOwner( WorldFrame, "ANCHOR_NONE" ) M.tooltip:ClearLines() M.tooltip:SetHyperlink( current_scan.link ) local num_lines = M.tooltip:NumLines() if num_lines and num_lines > 0 then local lines = {} for i = 1, num_lines do local line = getglobal( "GuildRecipesOctoTooltipTextLeft" .. i ) if line then table.insert( lines, line:GetText() or "" ) end end local scan_callback = current_scan.callback current_scan = nil M.tooltip_scan_frame:SetScript( "OnUpdate", nil ) scan_callback( lines ) start_next_scan() return end if current_scan.attempt >= current_scan.max_attempts then M.tooltip_scan_frame:SetScript( "OnUpdate", nil ) M.debug( "Failed to scan tooltip for " .. current_scan.link ) if current_scan.callback then current_scan.callback( nil ) end current_scan = nil start_next_scan() end end function start_next_scan() current_scan = table.remove( scan_queue, 1 ) if not current_scan then M.tooltip_scan_frame:SetScript( "OnUpdate", nil ) return end current_scan.attempt = 0 current_scan.elapsed = 0 M.tooltip_scan_frame:SetScript( "OnUpdate", process_scan ) end if not M.tooltip_scan_frame then M.tooltip_scan_frame = CreateFrame( "Frame" ) end table.insert( scan_queue, { link = link, callback = callback, max_attempts = max_attempts or 5, delay = delay or 0.1, } ) if not current_scan then start_next_scan() end end ---@param tradeskill string ---@return integer ---@nodiscard function M.tradeskill_hash( tradeskill ) local keys = {} local s = "" local ts = M.db.tradeskills[ tradeskill ] if ts then for k in pairs( ts ) do table.insert( keys, k ) end table.sort( keys ) for i = 1, getn( keys ) do local _, c = string.gsub( ts[ keys[ i ] ].p, ",", "" ) s = s .. keys[ i ] .. "=" .. c .. ";" end end return M.hash_string( s ) end ---@param str string ---@return integer function M.hash_string( str ) local hash = 5381 for i = 1, string.len( str ) do local c = string.byte( str, i ) hash = mod( hash * 33 + c, 4294967296 ) end return hash end --- @param hex string --- @return number r --- @return number g --- @return number b --- @return number a function M.hex_to_rgba( hex ) local r, g, b, a = string.match( hex, "^#?(%x%x)(%x%x)(%x%x)(%x?%x?)$" ) r, g, b = tonumber( r, 16 ) / 255, tonumber( g, 16 ) / 255, tonumber( b, 16 ) / 255 a = a ~= "" and tonumber( a, 16 ) / 255 or 1 return r, g, b, a end ---@param o any ---@return string function M.dump( o ) if not o then return "nil" end if type( o ) ~= 'table' then return tostring( o ) end local entries = 0 local s = "{" for k, v in pairs( o ) do if (entries == 0) then s = s .. " " end local key = type( k ) ~= "number" and '"' .. k .. '"' or k if (entries > 0) then s = s .. ", " end s = s .. "[" .. key .. "] = " .. M.dump( v ) entries = entries + 1 end if (entries > 0) then s = s .. " " end return s .. "}" end ---@diagnostic disable-next-line: undefined-field if not string.gmatch then string.gmatch = string.gfind end ---@diagnostic disable-next-line: duplicate-set-field string.match = function( str, pattern ) if not str then return nil end local _, _, r1, r2, r3, r4, r5, r6, r7, r8, r9 = string.find( str, pattern ) return r1, r2, r3, r4, r5, r6, r7, r8, r9 end return M