-- ------------------------------------------------------------------------------ -- -- TradeSkillMaster -- -- http://www.curse.com/addons/wow/tradeskill-master -- -- -- -- A TradeSkillMaster Addon (http://tradeskillmaster.com) -- -- All Rights Reserved* - Detailed license information included with addon. -- -- ------------------------------------------------------------------------------ -- -- This file contains code for scanning the auction house local TSM = select(2, ...) local AuctionScanning = TSM:NewModule("AuctionScanning", "AceEvent-3.0") TSMAPI.AuctionScan = {} local RETRY_DELAY = 2 local MAX_RETRIES = 4 local BASE_DELAY = 0.10 -- time to delay for before trying to scan a page again when it isn't fully loaded local private = { callbackHandler = nil, query = {}, options = {}, data = {}, isScanning = nil } TSMAPI:RegisterForTracing(private, "TradeSkillMaster.AuctionScanning_private") local scanCache = {} local CACHE_DECAY_PER_DAY = 5 local CACHE_AUTO_HIT_TIME = 10 * 60 local SECONDS_PER_DAY = 60 * 60 * 24 local function DoCallback(...) if type(private.callbackHandler) == "function" then private.callbackHandler(...) end end local function eventHandler(event) if event == "AUCTION_HOUSE_CLOSED" then -- auction house was closed, make sure all scanning is stopped AuctionScanning:UnregisterEvent("AUCTION_ITEM_LIST_UPDATE") private.auctionHouseShown = false DoCallback("INTERRUPTED") private:StopScanning() elseif event == "AUCTION_ITEM_LIST_UPDATE" then -- gets called whenever the AH window is updated (something is shown in the results section) AuctionScanning:UnregisterEvent("AUCTION_ITEM_LIST_UPDATE") TSMAPI:CancelFrame("updateDelay") -- now that our query was successful, we can get our data private:ScanAuctions() end end function AuctionScanning:OnEnable() AuctionScanning:RegisterEvent("AUCTION_HOUSE_CLOSED", eventHandler) end function private:ScanAuctionPage(resolveSellers) local shown = GetNumAuctionItems("list") local badData = false local auctions = {} for i = 1, shown do -- checks to make sure all the data has been sent to the client -- if not, the data is bad and we'll wait / try again -- local count, _, _, _, _, _, _, buyout, _, _, _, seller = select(3, GetAuctionItemInfo("list", i)) local count, _, _, _, _, _, buyout, _, _, seller = select(3, GetAuctionItemInfo("list", i)) local itemString = TSMAPI:GetItemString(GetAuctionItemLink("list", i)) auctions[i] = { itemString = itemString, index = i, count = count, buyout = buyout, seller = seller } if not (itemString and buyout and count and (seller or not resolveSellers or buyout == 0)) then badData = true end end return badData, auctions end function IsDuplicatePage() if not private.pageTemp or GetNumAuctionItems("list") == 0 then return false end local numLinks, prevLink = 0, nil for i = 1, GetNumAuctionItems("list") do -- local _, _, count, _, _, _, _, minBid, minInc, buyout, bid, _, _, seller = GetAuctionItemInfo("list", i) local _, _, count, _, _, _, minBid, minInc, buyout, bid, _, seller = GetAuctionItemInfo("list", i) local link = GetAuctionItemLink("list", i) local temp = private.pageTemp[i] if not prevLink then prevLink = link elseif prevLink ~= link then prevLink = link numLinks = numLinks + 1 end if not temp or temp.count ~= count or temp.minBid ~= minBid or temp.minInc ~= minInc or temp.buyout ~= buyout or temp.bid ~= bid or temp.seller ~= seller or temp.link ~= link then return false end end if numLinks > 1 and private.pageTemp.shown == GetNumAuctionItems("list") then return false end return true end local function PopulatePageTemp() local shown = GetNumAuctionItems("list") private.pageTemp = { numShown = shown } for i = 1, shown do -- checks to make sure all the data has been sent to the client -- if not, the data is bad and we'll wait / try again -- local _, _, count, _, _, _, _, minBid, minInc, buyout, bid, _, seller = GetAuctionItemInfo("list", i) local _, _, count, _, _, _, minBid, minInc, buyout, bid, _, seller = GetAuctionItemInfo("list", i) local link = GetAuctionItemLink("list", i) private.pageTemp[i] = { count = count, minBid = minBid, minInc = minInc, buyout = buyout, bid = bid, seller = seller, link = link } end end -- Starts a scan of the auction house. -- query - A single query containing QueryAuctionItem paramters: -- name, minLevel, maxLevel, invType, class, subClass, usable, quality -- resolveSellers - whether or not to resolve seller names -- maxPrice - stop scanning when prices go above this price function TSMAPI.AuctionScan:RunQuery(query, callbackHandler, resolveSellers, maxPrice, doCache) TSMAPI.AuctionScan:StopScan() -- stop any scan in progress if not AuctionFrame:IsVisible() then return -1 -- the auction house isn't open (return code -1) elseif type(query) ~= "table" then return -2 -- the scan queue is not a table (return code -2) elseif not CanSendAuctionQuery() then TSMAPI:CreateTimeDelay("cantSendAuctionQueryDelay", 0.1, function() TSMAPI.AuctionScan:RunQuery(query, callbackHandler, resolveSellers, maxPrice, doCache) end) return 0 -- the query will start as soon as it can but did not start immediately (return code 0) end -- sort by buyout SortAuctionItems("list", "buyout") if IsAuctionSortReversed("list", "buyout") then SortAuctionItems("list", "buyout") end -- setup the query private.query = CopyTable(query) private.query.page = 0 -- the current page of this query we're scanning private.query.timeDelay = 0 -- a delay used to wait for information to show up private.query.retries = 0 -- how many times we've done a hard retry so far private.query.hardRetry = nil -- if a page hasn't loaded after we've tried a delay, we'll do a hard retry and re-send the query private.cache = doCache and { query = CopyTable(query), items = {} } or nil -- setup other stuff wipe(private.data) private.isScanning = true private.callbackHandler = callbackHandler private.resolveSellers = resolveSellers private.scanType = "query" private.maxPrice = maxPrice or math.huge --starts scanning private:SendQuery() return 1 -- scan started successfully (return code 1) end function TSMAPI.AuctionScan:ScanLastPage(callbackHandler) private:StopScanning() -- stop any scan in progress if not AuctionFrame:IsVisible() then return -1 -- the auction house isn't open (return code -1) elseif not CanSendAuctionQuery() then TSMAPI:CreateTimeDelay("cantSendAuctionQueryDelay", 0.1, function() TSMAPI.AuctionScan:ScanLastPage(callbackHandler) end) return 0 -- the query will start as soon as it can but did not start immediately (return code 0) end -- clear the auction sort SortAuctionClearSort("list") -- setup the query private.query = {name="", page=0} private.query.timeDelay = 0 -- a delay used to wait for information to show up private.query.retries = 0 -- how many times we've done a hard retry so far private.query.hardRetry = nil -- if a page hasn't loaded after we've tried a delay, we'll do a hard retry and re-send the query -- setup other stuff wipe(private.data) private.isScanning = true private.callbackHandler = callbackHandler private.scanType = "lastPage" --starts scanning private:SendQuery() return 1 -- scan started successfully (return code 1) end -- sends a query to the AH frame once it is ready to be queried (uses frame as a delay) function private:SendQuery() if not private.isScanning then return end if CanSendAuctionQuery() then -- stop delay timer TSMAPI:CancelFrame("queryDelay") -- Query the auction house (then waits for AUCTION_ITEM_LIST_UPDATE to fire) AuctionScanning:RegisterEvent("AUCTION_ITEM_LIST_UPDATE", eventHandler) -- [exact] cardinal ruby 0 0 nil 0 0 0 0 0 -- [normal] cardinal ruby nil nil nil nil nil 0 nil nil QueryAuctionItems(private.query.name, private.query.minLevel, private.query.maxLevel, private.query.invType, private.query.class, private.query.subClass, private.query.page, private.query.usable, private.query.quality) else -- run delay timer then try again to scan TSMAPI:CreateTimeDelay("queryDelay", 0.05, private.SendQuery) end end --scans the currently shown page of auctions and collects all the data function private:ScanAuctions() if not private.isScanning then return end local shown, total = GetNumAuctionItems("list") local totalPages = ceil(total / NUM_AUCTION_ITEMS_PER_PAGE) if private.scanType == "numPages" then local cacheData = TSM.db.realm.numPagesCache[private.query.cacheKey] cacheData.lastScan = time() local confidence = (120 - cacheData.confidence) / (CACHE_DECAY_PER_DAY * 2) local diff = abs(cacheData.avg - totalPages) if diff <= 1 and diff > 0.5 then confidence = floor(confidence * (1.5 - diff)) elseif diff > 1 then confidence = floor(confidence - CACHE_DECAY_PER_DAY * diff) end cacheData.confidence = max(floor(cacheData.confidence + confidence), 0) cacheData.avg = (cacheData.avg * cacheData.numScans + totalPages) / (cacheData.numScans + 1) cacheData.numScans = cacheData.numScans + 1 private:StopScanning() return DoCallback("NUM_PAGES", totalPages) elseif private.scanType == "lastPage" then local lastPage = floor(total / NUM_AUCTION_ITEMS_PER_PAGE) if private.query.page ~= lastPage then private.query.page = lastPage return private:SendQuery() end end local dataIsBad, auctions = private:ScanAuctionPage(private.resolveSellers) -- check that we have good data if dataIsBad or IsDuplicatePage() then if private.query.retries < MAX_RETRIES then if private.query.hardRetry then -- Hard retry -- re-sends the entire query private.query.retries = private.query.retries + 1 private.query.timeDelay = 0 private.query.hardRetry = nil private:SendQuery() else -- Soft retry -- runs a delay and then tries to scan the query again private.query.timeDelay = private.query.timeDelay + BASE_DELAY TSMAPI:CreateTimeDelay("updateDelay", BASE_DELAY, private.ScanAuctions) -- If after 2 seconds of retrying we still don't have data, will go and requery to try and solve the issue -- if we still don't have data, we try to scan it anyway and move on. if private.query.timeDelay >= RETRY_DELAY then private.query.hardRetry = true end end return end end if private.cache then -- store info in cache for i, v in ipairs(auctions) do local cacheTmp = CopyTable(v) cacheTmp.index = private.query.page * 50 + i tinsert(private.cache, cacheTmp) private.cache.items[cacheTmp.itemString] = true end end private.query.hardRetry = nil private.query.retries = 0 private.query.timeDelay = 0 if private.scanType ~= "lastPage" then private.query.page = private.query.page + 1 -- increment current page if totalPages > 0 then DoCallback("SCAN_PAGE_UPDATE", private.query.page, totalPages) end end PopulatePageTemp() -- now that we know our query is good, time to verify and then store our data for _, v in ipairs(auctions) do if private:AddAuctionRecord(v.index) then -- we've hit the max price so we're done scanning private:StopScanning() return DoCallback("SCAN_COMPLETE", private.data) end end if private.scanType == "lastPage" then return DoCallback("SCAN_LAST_PAGE_COMPLETE", private.data) elseif private.query.page >= totalPages then -- we have finished scanning this query private:StopScanning() return DoCallback("SCAN_COMPLETE", private.data) end -- query the next page and continue scanning private:SendQuery() end -- Add a new record to the private.data table function private:AddAuctionRecord(index) -- local name, texture, count, _, _, _, _, minBid, minIncrement, buyout, bid, highBidder, highBidder_full, seller, seller_full = GetAuctionItemInfo("list", index) local name, texture, count, _, _, _, minBid, minIncrement, buyout, bid, highBidder, seller = GetAuctionItemInfo("list", index) seller = TSM:GetAuctionPlayer(seller, null) highBidder = TSM:GetAuctionPlayer(highBidder, null) local timeLeft = GetAuctionItemTimeLeft("list", index) local link = GetAuctionItemLink("list", index) local itemString = TSMAPI:GetItemString(link) if not itemString then return end -- Create a new entry in the table if not private.data[itemString] then private.data[itemString] = TSMAPI.AuctionScan:NewAuctionItem() private.data[itemString]:SetItemLink(link) private.data[itemString]:SetTexture(texture) end private.data[itemString]:AddAuctionRecord(count, minBid, minIncrement, buyout, bid, highBidder, seller or "?", timeLeft) -- add the base item if necessary local baseItemString = TSMAPI:GetBaseItemString(itemString) if baseItemString ~= itemString then -- Create a new entry in the table if not private.data[baseItemString] then private.data[baseItemString] = TSMAPI.AuctionScan:NewAuctionItem() private.data[baseItemString]:SetItemLink(link) private.data[baseItemString]:SetTexture(texture) end private.data[baseItemString]:AddAuctionRecord(count, minBid, minIncrement, buyout, bid, highBidder, seller or "?", timeLeft) private.data[baseItemString].isBaseItem = true end if select(8, TSMAPI:GetSafeItemInfo(link)) == count then return (buyout or 0) / count > (private.maxPrice or math.huge) end end -- stops the scan when we are finished scanning, it was interrupted, or somebody stopped it function private:StopScanning() TSMAPI:CancelFrame("cantSendAuctionQueryDelay") if not private.isScanning then return end if private.cache then -- store the cache info sort(private.cache, function(a, b) return a.index < b.index end) for itemString in pairs(private.cache.items) do scanCache[itemString] = private.cache end wipe(private.cache.items) private.cache = nil end -- cancel any delays that might still be running TSMAPI:CancelFrame("queryDelay") TSMAPI:CancelFrame("updateDelay") AuctionScanning:UnregisterEvent("AUCTION_ITEM_LIST_UPDATE") private.isScanning = nil private.pageTemp = nil end -- API for stopping the scan -- returns true/false if we were/weren't actually scanning function TSMAPI.AuctionScan:StopScan() private:StopScanning() TSM:StopGeneratingQueries() end -- Gets the number of pages for a given query function TSMAPI.AuctionScan:GetNumPages(query, callbackHandler) private:StopScanning() -- stop any scan in progress if not AuctionFrame:IsVisible() then return -1 -- the auction house isn't open (return code -1) elseif type(query) ~= "table" then return -2 -- the scan queue is not a table (return code -2) elseif not CanSendAuctionQuery() then TSMAPI:CreateTimeDelay("cantSendAuctionQueryDelay", 0.1, function() TSMAPI.AuctionScan:GetNumPages(query, callbackHandler) end) return 0 -- the query will start as soon as it can but did not start immediately (return code 0) end -- fancy caching local temp = {} for i, field in ipairs({ "name", "minLevel", "maxLevel", "invType", "class", "subClass", "usable", "quality" }) do temp[i] = tostring(query[field]) end local cacheKey = table.concat(temp, "~") local cacheData = TSM.db.realm.numPagesCache[cacheKey] if cacheData then local cacheHit if time() - cacheData.lastScan < CACHE_AUTO_HIT_TIME then -- auto cache hit cacheHit = true elseif random(1, 100) <= cacheData.confidence then -- cache hit cacheData.confidence = cacheData.confidence - floor(((time() - cacheData.lastScan) / SECONDS_PER_DAY) * CACHE_DECAY_PER_DAY + 0.5) cacheData.confidence = max(cacheData.confidence, 0) -- ensure >= 0 cacheHit = true end if cacheHit then local numPages = max(ceil(cacheData.avg), 1) -- round avg num of pages up and ensure >= 1 TSMAPI:CreateTimeDelay("numPagesCacheDelay", 0, function() callbackHandler("NUM_PAGES", numPages) end) return 2 end else TSM.db.realm.numPagesCache[cacheKey] = { avg = 0, confidence = 0, numScans = 0, lastScan = 0 } end -- setup the query private.query = CopyTable(query) private.query.cacheKey = cacheKey -- setup other stuff wipe(private.data) private.isScanning = true private.callbackHandler = callbackHandler private.scanType = "numPages" --starts scanning private:SendQuery() return 1 -- scan started successfully (return code 1) end function TSMAPI.AuctionScan:CacheRemove(itemString, index) if scanCache[itemString] then tremove(scanCache[itemString], index) end end function TSMAPI.AuctionScan:ClearCache() wipe(scanCache) end local findPrivate = {} findPrivate.findFrame = findPrivate.findFrame or CreateFrame("Frame") local function eventHandler(frame, event) if event == "AUCTION_HOUSE_SHOW" then -- auction house was opened elseif event == "AUCTION_HOUSE_CLOSED" then frame:UnregisterEvent("AUCTION_ITEM_LIST_UPDATE") if findPrivate.isScanning then -- stop scanning if we were scanning (pass true to specify it was interrupted) TSMAPI.AuctionScan:StopFindScan() end elseif event == "AUCTION_ITEM_LIST_UPDATE" then frame:UnregisterEvent("AUCTION_ITEM_LIST_UPDATE") if findPrivate.isScanning then findPrivate.timeDelay = 0 TSMAPI:CancelFrame("auctionFindScanDelay") -- now that our query was successful we can get our data findPrivate:ScanAuctions() end end end findPrivate.findFrame:SetScript("OnEvent", eventHandler) findPrivate.findFrame:RegisterEvent("AUCTION_HOUSE_CLOSED") findPrivate.findFrame:RegisterEvent("AUCTION_HOUSE_SHOW") local function CompareTableKeys(tbl1, tbl2) for _, key in ipairs(findPrivate.keys) do if tbl1[key] ~= tbl2[key] then return end end return true end local function IsTargetAuction(index) local itemString = TSMAPI:GetItemString(GetAuctionItemLink("list", index)) -- local _, _, count, _, _, _, _, minBid, bidIncrement, buyout, bidAmount, _, _, seller, seller_full = GetAuctionItemInfo("list", index) local _, _, count, _, _, _, minBid, bidIncrement, buyout, bidAmount, _, _, seller = GetAuctionItemInfo("list", index) seller = TSM:GetAuctionPlayer(seller, nil) local bid = bidAmount == 0 and minBid or bidAmount local tmp = { itemString = itemString, count = count, bid = bid, buyout = buyout, seller = seller } return CompareTableKeys(tmp, findPrivate.targetInfo) end -- valid targetInfo keys: itemString, count, bid, buyout, seller function TSMAPI.AuctionScan:FindAuction(callback, targetInfo, useCache) if findPrivate.isScanning then TSMAPI.AuctionScan:StopFindScan() end findPrivate.keys = { "itemString", "count", "bid", "buyout", "seller" } for i = #findPrivate.keys, 1, -1 do if not targetInfo[findPrivate.keys[i]] then tremove(findPrivate.keys, i) end end local cacheIndex if useCache and scanCache[targetInfo.itemString] then for i, v in ipairs(scanCache[targetInfo.itemString]) do if CompareTableKeys(v, targetInfo) then cacheIndex = i break end end end if cacheIndex then findPrivate.page = floor((cacheIndex - 1) / 50) findPrivate.query = scanCache[targetInfo.itemString].query else local name, _, rarity, _, minLevel, class, subClass = TSMAPI:GetSafeItemInfo(targetInfo.itemString) findPrivate.query = { name = name, minLevel = minLevel, maxLevel = minLevel, class = class, subClass = subClass, rarity = rarity } findPrivate.page = 0 end findPrivate.targetInfo = targetInfo findPrivate.callback = callback findPrivate.cacheIndex = cacheIndex findPrivate.isScanning = targetInfo.itemString findPrivate.retries = 0 findPrivate.hardRetry = nil -- check if the item is on the current page for i = 1, GetNumAuctionItems("list") do if IsTargetAuction(i) then TSMAPI.AuctionScan:StopFindScan() TSMAPI:CreateTimeDelay("queryFoundDelay", 0.1, function() findPrivate.callback(i) end) return end end findPrivate:SendQuery() end -- sends a query to the AH frame once it is ready to be queried (uses frame as a delay) function findPrivate:SendQuery() if not findPrivate.isScanning then return end if CanSendAuctionQuery() then -- stop delay timer TSMAPI:CancelFrame("auctionFindQueryDelay") -- query the auction house (then waits for AUCTION_ITEM_LIST_UPDATE to fire) findPrivate.findFrame:RegisterEvent("AUCTION_ITEM_LIST_UPDATE") local q = findPrivate.query QueryAuctionItems(q.name, q.minLevel, q.maxLevel, q.invType, q.class, q.subClass, findPrivate.page, 0, q.rarity) else -- run delay timer then try again to scan TSMAPI:CreateTimeDelay("auctionFindQueryDelay", 0.05, function() findPrivate:SendQuery() end) end end -- scans the currently shown page of auctions and collects all the data function findPrivate:ScanAuctions() if not findPrivate.isScanning then return end -- collects data on the query: -- # of auctions on current page -- # of pages total local shown, total = GetNumAuctionItems("list") local totalPages = math.ceil(total / 50) local dataIsBad, temp = private:ScanAuctionPage(findPrivate.targetInfo.seller) -- Check for bad data if findPrivate.retries < 3 then if dataIsBad then if findPrivate.hardRetry then -- Hard retry -- re-sends the entire query findPrivate.retries = findPrivate.retries + 1 findPrivate:SendQuery() else -- Soft retry -- runs a delay and then tries to scan the query again findPrivate.timeDelay = findPrivate.timeDelay + BASE_DELAY TSMAPI:CreateTimeDelay("auctionFindScanDelay", BASE_DELAY, findPrivate.ScanAuctions) -- If after 4 seconds of retrying we still don't have data, will go and requery to try and solve the issue -- if we still don't have data, we try to scan it anyway and move on. if findPrivate.timeDelay >= 4 then findPrivate.hardRetry = true findPrivate.retries = 0 end end return end end findPrivate.hardRetry = nil findPrivate.retries = 0 -- now that we know our query is good, time to verify and then store our data for i = 1, shown do if IsTargetAuction(temp[i].index) then TSMAPI.AuctionScan:StopFindScan() return findPrivate.callback(temp[i].index, findPrivate.cacheIndex == findPrivate.page and findPrivate.page * 50 + temp[i].index) end end -- This query has more pages to scan -- increment the page # and send the new query if not findPrivate.cacheIndex and totalPages > (findPrivate.page + 1) then findPrivate.page = findPrivate.page + 1 findPrivate:SendQuery() return end -- we are done scanning! TSMAPI.AuctionScan:StopFindScan() return findPrivate.callback() end -- returns whether or not we're currently doing a find scan function TSMAPI.AuctionScan:IsFindScanning() return findPrivate.isScanning end -- stops the scan because it was either interrupted or it was completed successfully function TSMAPI.AuctionScan:StopFindScan() findPrivate.findFrame:UnregisterEvent("AUCTION_ITEM_LIST_UPDATE") findPrivate.isScanning = nil TSMAPI:CancelFrame("auctionFindQueryDelay") TSMAPI:CancelFrame("auctionFindScanDelay") end