From b7d89d90bf594986025fc89a905a89e41ef09ebf Mon Sep 17 00:00:00 2001 From: j2 Date: Sun, 27 Sep 2026 20:37:42 -0500 Subject: [PATCH] profession pricing + territory colors --- .gitignore | 1 + .../ClassicTerritoryColors.lua | 0 .../ClassicTerritoryColors.toc | 0 ProfessionPricer/ProfessionPricer.lua | 709 ++++++++++++++++++ ProfessionPricer/ProfessionPricer.toc | 8 + ProfessionPricer/README.md | 13 + 6 files changed, 731 insertions(+) mode change 100644 => 100755 .gitignore mode change 100644 => 100755 ClassicTerritoryColors/ClassicTerritoryColors.lua mode change 100644 => 100755 ClassicTerritoryColors/ClassicTerritoryColors.toc create mode 100755 ProfessionPricer/ProfessionPricer.lua create mode 100755 ProfessionPricer/ProfessionPricer.toc create mode 100755 ProfessionPricer/README.md diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 965adf8..c2eabd6 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ !/.gitignore !/ClassicTerritoryColors/ !/EquippedItemLevel/ +!/ProfessionPricer/ !/SetGuard/ !/SuffixQuery/ diff --git a/ClassicTerritoryColors/ClassicTerritoryColors.lua b/ClassicTerritoryColors/ClassicTerritoryColors.lua old mode 100644 new mode 100755 diff --git a/ClassicTerritoryColors/ClassicTerritoryColors.toc b/ClassicTerritoryColors/ClassicTerritoryColors.toc old mode 100644 new mode 100755 diff --git a/ProfessionPricer/ProfessionPricer.lua b/ProfessionPricer/ProfessionPricer.lua new file mode 100755 index 0000000..50fe44b --- /dev/null +++ b/ProfessionPricer/ProfessionPricer.lua @@ -0,0 +1,709 @@ +local ADDON = ... +local app = CreateFrame("Frame") +local DB +local scanButton +local cacheRevision = 0 +local updateCraftPriceUI + +local function say(message) + print("|cff33ff99Profession Pricer:|r " .. message) +end + +local function comma(value) + local result = tostring(math.floor(tonumber(value) or 0)) + while true do + local nextResult, count = result:gsub("^(-?%d+)(%d%d%d)", "%1,%2") + result = nextResult + if count == 0 then return result end + end +end + +local GOLD = "|TInterface\\MoneyFrame\\UI-GoldIcon:0:0:2:0|t" +local SILVER = "|TInterface\\MoneyFrame\\UI-SilverIcon:0:0:2:0|t" +local COPPER = "|TInterface\\MoneyFrame\\UI-CopperIcon:0:0:2:0|t" + +local function money(copper) + copper = math.max(0, math.floor(tonumber(copper) or 0)) + local gold = math.floor(copper / 10000) + local silver = math.floor((copper % 10000) / 100) + local coins = copper % 100 + local parts = {} + if gold > 0 then parts[#parts + 1] = gold .. GOLD end + if silver > 0 or gold > 0 then parts[#parts + 1] = silver .. SILVER end + parts[#parts + 1] = coins .. COPPER + return table.concat(parts, " ") +end + +local function age(timestamp) + if not timestamp then return "unknown" end + local seconds = math.max(0, time() - timestamp) + if seconds < 60 then return "<1m" end + if seconds < 3600 then return math.floor(seconds / 60) .. "m" end + if seconds < 86400 then return math.floor(seconds / 3600) .. "h" end + return math.floor(seconds / 86400) .. "d" +end + +local function countPrices() + local count = 0 + for _ in pairs(DB.prices) do count = count + 1 end + return count +end + +local function cachedPrice(itemID) + local entry = DB and DB.prices and DB.prices[tonumber(itemID)] + return entry and tonumber(entry.price) or nil +end + +local function savePrice(itemID, price, source) + itemID = tonumber(itemID) + price = tonumber(price) + if not itemID or not price or price <= 0 then return end + price = math.floor(price) + if price <= 0 then return end + local old = DB.prices[itemID] + DB.prices[itemID] = {price = price, updated = time(), source = source} + if not old or old.price ~= price then cacheRevision = cacheRevision + 1 end +end + +local function lowestCommodityPrice(itemID) + if not (C_AuctionHouse and C_AuctionHouse.GetNumCommoditySearchResults and C_AuctionHouse.GetCommoditySearchResultInfo) then return nil end + local ok, count = pcall(C_AuctionHouse.GetNumCommoditySearchResults, itemID) + if not ok or not count then return nil end + local best + for i = 1, count do + local success, result = pcall(C_AuctionHouse.GetCommoditySearchResultInfo, itemID, i) + local price = success and result and tonumber(result.unitPrice) + if price and price > 0 and (not best or price < best) then best = price end + end + return best +end + +local function lowestItemPrice(itemKey) + if not (C_AuctionHouse and C_AuctionHouse.GetNumItemSearchResults and C_AuctionHouse.GetItemSearchResultInfo) then return nil end + local ok, count = pcall(C_AuctionHouse.GetNumItemSearchResults, itemKey) + if not ok or not count then return nil end + local best + for i = 1, count do + local success, result = pcall(C_AuctionHouse.GetItemSearchResultInfo, itemKey, i) + local price = success and result and tonumber(result.buyoutAmount) + if price and price > 0 and (not best or price < best) then best = price end + end + return best +end + +local browse = {active = false, token = 0} +local updateScanButton + +local function countSeen() + local count = 0 + for _ in pairs(browse.seen or {}) do count = count + 1 end + return count +end + +local function endBrowse(reason, completed) + if not browse.active then return end + browse.active = false + browse.token = browse.token + 1 + local count = countSeen() + for itemID, price in pairs(browse.prices) do savePrice(itemID, price, "browse") end + if completed then + DB.lastBrowseScan = time() + DB.lastBrowseItems = count + end + updateScanButton() + say((completed and "AH browse scan complete: " or "AH browse scan stopped: ") .. comma(count) .. " item summaries cached (" .. reason .. ").") +end + +local function cacheBrowseResults() + if not (C_AuctionHouse and C_AuctionHouse.GetBrowseResults) then return 0, 0 end + local ok, results = pcall(C_AuctionHouse.GetBrowseResults) + if not ok or type(results) ~= "table" then return 0, 0 end + local added = 0 + for _, result in ipairs(results) do + local key = result.itemKey + local itemID = key and tonumber(key.itemID) + local price = tonumber(result.minPrice) + if itemID and price and price > 0 then + if browse.active then + local signature = itemID .. ":" .. tostring(key.itemLevel or 0) .. ":" .. tostring(key.itemSuffix or 0) .. ":" .. tostring(key.battlePetSpeciesID or 0) + if not browse.seen[signature] then + browse.seen[signature] = true + added = added + 1 + end + local prior = browse.prices[itemID] + if not prior or price < prior then browse.prices[itemID] = price end + else + local old = DB.prices[itemID] + if not old or not old.price or price < old.price + or (old.source == "browse" and old.updated and time() - old.updated > 60) then + savePrice(itemID, price, "browse") + end + end + end + end + return added, #results +end + +local function requestMoreBrowse(token) + if not browse.active or browse.token ~= token then return end + if C_AuctionHouse.IsThrottledMessageSystemReady then + local ok, ready = pcall(C_AuctionHouse.IsThrottledMessageSystemReady) + if ok and not ready then + C_Timer.After(0.75, function() requestMoreBrowse(token) end) + return + end + end + browse.waiting = true + local ok = pcall(C_AuctionHouse.RequestMoreBrowseResults) + if not ok then endBrowse("request failed", false) end +end + +local function browseResultsUpdated() + local added, total = cacheBrowseResults() + if not browse.active or not browse.waiting then return end + browse.waiting = false + browse.lastActivity = time() + browse.pages = browse.pages + 1 + updateScanButton() + + if C_AuctionHouse.HasFullBrowseResults then + local ok, full = pcall(C_AuctionHouse.HasFullBrowseResults) + if ok and full then endBrowse("full results", true); return end + end + if total == browse.lastCount and added == 0 then + browse.stable = browse.stable + 1 + else + browse.stable = 0 + end + browse.lastCount = total + if browse.stable >= 3 then endBrowse("no more results", true); return end + if not C_AuctionHouse.RequestMoreBrowseResults then + endBrowse("pagination unavailable", false) + return + end + local token = browse.token + C_Timer.After(0.35, function() requestMoreBrowse(token) end) +end + +local function browseWatchdog(token) + if not browse.active or browse.token ~= token then return end + if time() - browse.lastActivity >= 20 then + endBrowse("no response from Auction House", false) + else + C_Timer.After(5, function() browseWatchdog(token) end) + end +end + +local function startBrowseScan() + if browse.active then say("AH browse scan is already running (" .. comma(countSeen()) .. " summaries found).") return end + if not (AuctionHouseFrame and AuctionHouseFrame:IsShown()) then say("Open the Auction House before scanning.") return end + if not (C_AuctionHouse and C_AuctionHouse.SendBrowseQuery and C_AuctionHouse.GetBrowseResults) then + say("AH browse scanning API is unavailable on this client.") + return + end + browse.token = browse.token + 1 + browse.active = true + browse.waiting = true + browse.seen = {} + browse.prices = {} + browse.pages = 0 + browse.stable = 0 + browse.lastCount = 0 + browse.lastActivity = time() + updateScanButton() + + local filters = {} + if Enum and Enum.AuctionHouseFilter then + for _, name in ipairs({"PoorQuality", "CommonQuality", "UncommonQuality", "RareQuality", "EpicQuality", "LegendaryQuality", "ArtifactQuality"}) do + local filter = Enum.AuctionHouseFilter[name] + if filter ~= nil then filters[#filters + 1] = filter end + end + end + local sorts = {} + if Enum and Enum.AuctionHouseSortOrder then + if Enum.AuctionHouseSortOrder.Price ~= nil then + sorts[#sorts + 1] = {sortOrder = Enum.AuctionHouseSortOrder.Price, reverseSort = false} + end + if Enum.AuctionHouseSortOrder.Name ~= nil then + sorts[#sorts + 1] = {sortOrder = Enum.AuctionHouseSortOrder.Name, reverseSort = false} + end + end + local ok, err = pcall(C_AuctionHouse.SendBrowseQuery, {searchString = "", sorts = sorts, filters = filters}) + if not ok then endBrowse("query failed: " .. tostring(err), false); return end + say("starting AH browse scan...") + local token = browse.token + C_Timer.After(5, function() browseWatchdog(token) end) +end + +updateScanButton = function() + if not scanButton then return end + scanButton:SetText(browse.active and "Scanning..." or "Scan AH") + if browse.active then + scanButton.status:SetText("|cffffff66" .. comma(countSeen()) .. " found|r") + elseif DB.lastBrowseScan then + scanButton.status:SetText("|cff33ff66Last: " .. age(DB.lastBrowseScan) .. " ago|r") + else + scanButton.status:SetText("|cffaaaaaaNot scanned|r") + end +end + +local function createScanButton() + if scanButton or not AuctionHouseFrame then return end + local button = CreateFrame("Button", "ProfessionPricerScanButton", AuctionHouseFrame, "UIPanelButtonTemplate") + button:SetSize(92, 24) + if AuctionHouseFrameAuctionsTab then + button:SetPoint("LEFT", AuctionHouseFrameAuctionsTab, "RIGHT", 8, 0) + elseif AuctionHouseFrame.BuyTab then + button:SetPoint("BOTTOMLEFT", AuctionHouseFrame, "BOTTOMLEFT", 310, 4) + else + button:SetPoint("BOTTOMLEFT", AuctionHouseFrame, "BOTTOMLEFT", 305, 4) + end + button:SetFrameLevel((AuctionHouseFrame:GetFrameLevel() or 1) + 20) + local status = button:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + status:SetPoint("TOP", button, "BOTTOM", 0, -1) + button.status = status + button:SetScript("OnClick", startBrowseScan) + button:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_TOP") + GameTooltip:SetText("Profession Pricer AH Scan") + if browse.active then + GameTooltip:AddLine(comma(countSeen()) .. " item summaries discovered", 1, 1, 1) + elseif DB.lastBrowseScan then + GameTooltip:AddLine("Last scan: " .. age(DB.lastBrowseScan) .. " ago", 1, 1, 1) + GameTooltip:AddLine(comma(DB.lastBrowseItems or 0) .. " item summaries cached", 1, 1, 1) + else + GameTooltip:AddLine("No scan completed yet.", 1, 1, 1) + end + GameTooltip:Show() + end) + button:SetScript("OnLeave", function() GameTooltip:Hide() end) + scanButton = button + updateScanButton() +end + +local replicate = {active = false, token = 0} +local replicationUpdated + +local function finishReplication() + if not replicate.active then return end + local count = 0 + for itemID, price in pairs(replicate.prices) do + savePrice(itemID, price, "replicate") + count = count + 1 + end + DB.lastFullScan = time() + DB.lastFullItems = count + DB.lastFullAuctions = replicate.auctions + replicate.active = false + replicate.token = replicate.token + 1 + say("full AH scan complete: " .. comma(count) .. " item prices from " .. comma(replicate.auctions) .. " auctions.") +end + +local function processReplicationBatch(token) + if not replicate.active or replicate.token ~= token then return end + local stop = math.min(replicate.index + 249, replicate.total - 1) + while replicate.index <= stop do + local ok, _, _, quantity, _, _, _, _, _, _, buyout, _, _, _, _, _, _, itemID = pcall(C_AuctionHouse.GetReplicateItemInfo, replicate.index) + if ok and itemID and tonumber(buyout) and buyout > 0 then + quantity = math.max(1, tonumber(quantity) or 1) + local unitPrice = math.floor(buyout / quantity) + if unitPrice > 0 and (not replicate.prices[itemID] or unitPrice < replicate.prices[itemID]) then + replicate.prices[itemID] = unitPrice + end + replicate.auctions = replicate.auctions + 1 + end + replicate.index = replicate.index + 1 + end + if replicate.index >= replicate.total then + finishReplication() + else + C_Timer.After(0.02, function() processReplicationBatch(token) end) + end +end + +replicationUpdated = function() + if not replicate.active or not replicate.waiting then return end + local ok, count = pcall(C_AuctionHouse.GetNumReplicateItems) + if not ok then + replicate.active = false + say("could not read replicated AH snapshot.") + return + end + if not count or count <= 0 then return end + replicate.waiting = false + replicate.total = count + replicate.index = 0 + say("AH snapshot received: " .. comma(count) .. " auctions; building price cache...") + processReplicationBatch(replicate.token) +end + +local function startFullScan() + if replicate.active then say("full AH scan is already running.") return end + if not (AuctionHouseFrame and AuctionHouseFrame:IsShown()) then say("Open the Auction House before scanning.") return end + if not (C_AuctionHouse and C_AuctionHouse.ReplicateItems and C_AuctionHouse.GetNumReplicateItems and C_AuctionHouse.GetReplicateItemInfo) then + say("AH replication API is unavailable on this client.") + return + end + replicate.token = replicate.token + 1 + replicate.active = true + replicate.waiting = true + replicate.index = 0 + replicate.total = 0 + replicate.auctions = 0 + replicate.prices = {} + local ok, err = pcall(C_AuctionHouse.ReplicateItems) + if not ok then + replicate.active = false + say("AH snapshot request failed: " .. tostring(err)) + return + end + say("requesting full AH snapshot...") + local token = replicate.token + local tries = 0 + local function poll() + if not replicate.active or not replicate.waiting or replicate.token ~= token then return end + tries = tries + 1 + replicationUpdated() + if not replicate.waiting then return end + if tries >= 30 then + replicate.active = false + say("no replicated AH snapshot arrived; scan stopped.") + else + C_Timer.After(0.5, poll) + end + end + C_Timer.After(0.5, poll) +end + +local craft = {owner = nil, frame = nil, reagentLines = {}, recipeID = nil, revision = -1} + +local function selectedRecipe() + local form = ProfessionsFrame and ProfessionsFrame.CraftingPage and ProfessionsFrame.CraftingPage.SchematicForm + if not form or type(form.GetRecipeInfo) ~= "function" then return nil, form end + local ok, info = pcall(form.GetRecipeInfo, form) + if not ok or not info then return nil, form end + return info.recipeID, form +end + +local function recipePricing(recipeID) + if not (recipeID and C_TradeSkillUI and type(C_TradeSkillUI.GetRecipeSchematic) == "function") then return nil end + local ok, schematic = pcall(C_TradeSkillUI.GetRecipeSchematic, recipeID, false) + if not ok or not schematic then return nil end + local result = {cost = 0, reagents = {}, outputID = schematic.outputItemID, outputQty = tonumber(schematic.quantityMin) or 1} + for _, slot in ipairs(schematic.reagentSlotSchematics or {}) do + local quantity = tonumber(slot.quantityRequired) or 0 + local reagent = slot.reagents and slot.reagents[1] + if reagent and reagent.itemID and quantity > 0 then + local price = cachedPrice(reagent.itemID) + result.reagents[#result.reagents + 1] = {quantity = quantity, price = price} + if price then result.cost = result.cost + price * quantity else result.missing = true end + end + end + local outputPrice = result.outputID and cachedPrice(result.outputID) + result.outputValue = outputPrice and outputPrice * result.outputQty or nil + return result +end + +local SORT_MODES = {"default", "cost", "profit"} +local SORT_LABELS = {default = "Default", cost = "Craft Cost: low first", profit = "Profit/Craft: high first"} +local recipeSort = {scrollBox = nil, button = nil, revision = -1} + +local function sortedRecipeProvider(source) + if not DB or DB.sortMode == "default" or not source or type(CreateTreeDataProvider) ~= "function" + or type(source.EnumerateEntireRange) ~= "function" then + return source + end + + local rows, seen = {}, {} + local ok = pcall(function() + for _, node in source:EnumerateEntireRange() do + local data = node:GetData() + local info = data and data.recipeInfo + if info and info.recipeID and not info.favoritesInstance and not seen[info.recipeID] then + seen[info.recipeID] = true + local pricing = recipePricing(info.recipeID) + local score + if pricing and not pricing.missing then + if DB.sortMode == "cost" then + score = pricing.cost + elseif pricing.outputValue then + score = pricing.outputValue - pricing.cost + end + end + rows[#rows + 1] = {info = info, score = score, learned = info.learned ~= false} + end + end + end) + if not ok or #rows == 0 then return source end + + table.sort(rows, function(a, b) + -- Keep recipes the character cannot craft after learned recipes. + if a.learned ~= b.learned then return a.learned end + if a.score == nil and b.score ~= nil then return false end + if b.score == nil and a.score ~= nil then return true end + if a.score ~= nil and b.score ~= nil and a.score ~= b.score then + if DB.sortMode == "cost" then return a.score < b.score end + return a.score > b.score + end + local aName = (a.info.name or ""):lower() + local bName = (b.info.name or ""):lower() + if aName ~= bName then return aName < bName end + return a.info.recipeID < b.info.recipeID + end) + + local built, flat = pcall(function() + local provider = CreateTreeDataProvider() + local root = provider:GetRootNode() + for _, row in ipairs(rows) do root:Insert({recipeInfo = row.info, order = 0}) end + return provider + end) + return built and flat or source +end + +local function refreshSortedRecipes() + local page = ProfessionsFrame and ProfessionsFrame.CraftingPage + if not page or not page.professionInfo or type(page.Init) ~= "function" then return end + recipeSort.revision = cacheRevision + local ok, err = pcall(page.Init, page, page.professionInfo) + if not ok then say("could not refresh recipe order: " .. tostring(err)) end +end + +local function setSortMode(mode) + if mode ~= "default" and mode ~= "cost" and mode ~= "profit" then + say("Use /pp sort default, /pp sort cost, or /pp sort profit.") + return + end + DB.sortMode = mode + if recipeSort.button then recipeSort.button:SetText("Sort: " .. SORT_LABELS[mode]) end + refreshSortedRecipes() +end + +local function ensureRecipeSort() + local page = ProfessionsFrame and ProfessionsFrame.CraftingPage + local list = page and page.RecipeList + local scrollBox = list and list.ScrollBox + if not scrollBox or type(scrollBox.SetDataProvider) ~= "function" or type(CreateTreeDataProvider) ~= "function" then return end + + if recipeSort.scrollBox ~= scrollBox then + local nativeSetDataProvider = scrollBox.SetDataProvider + scrollBox.SetDataProvider = function(self, provider, ...) + local sorted, displayProvider = pcall(sortedRecipeProvider, provider) + recipeSort.revision = cacheRevision + return nativeSetDataProvider(self, sorted and displayProvider or provider, ...) + end + recipeSort.scrollBox = scrollBox + if recipeSort.button then recipeSort.button:Hide() end + recipeSort.button = nil + end + + if not recipeSort.button then + local button = CreateFrame("Button", nil, list, "UIPanelButtonTemplate") + button:SetSize(260, 22) + button:SetPoint("TOPLEFT", list, "BOTTOMLEFT", 6, -10) + button:SetFrameLevel((list:GetFrameLevel() or 1) + 5) + button:SetScript("OnClick", function() + local current = DB.sortMode or "default" + for index, mode in ipairs(SORT_MODES) do + if mode == current then + setSortMode(SORT_MODES[(index % #SORT_MODES) + 1]) + return + end + end + setSortMode("default") + end) + button:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_TOP") + GameTooltip:SetText("Profession Pricer Recipe Sort") + GameTooltip:AddLine("Click to cycle through default order, lowest AH craft cost, and highest estimated profit per craft.", 1, 1, 1, true) + GameTooltip:AddLine("Unlearned and unpriced recipes follow priced learned recipes.", .8, .8, .8, true) + GameTooltip:Show() + end) + button:SetScript("OnLeave", function() GameTooltip:Hide() end) + recipeSort.button = button + button:SetText("Sort: " .. SORT_LABELS[DB.sortMode or "default"]) + if DB.sortMode ~= "default" then refreshSortedRecipes() end + end +end + +local function hideCraft() + if craft.frame then craft.frame:Hide() end + for _, line in ipairs(craft.reagentLines) do line:Hide() end + craft.recipeID = nil +end + +local function ensureCraftFrame(form) + if craft.owner == form and craft.frame then return end + hideCraft() + craft.owner = form + craft.reagentLines = {} + local frame = CreateFrame("Frame", nil, form) + frame:SetSize(190, 42) + frame:SetPoint("TOPRIGHT", form, "TOPRIGHT", -18, -48) + frame:SetFrameLevel((form:GetFrameLevel() or 1) + 30) + local cost = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + cost:SetPoint("TOPRIGHT", 0, 0) + cost:SetWidth(190) + cost:SetJustifyH("RIGHT") + local value = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + value:SetPoint("TOPRIGHT", cost, "BOTTOMRIGHT", 0, -2) + value:SetWidth(190) + value:SetJustifyH("RIGHT") + local margin = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + margin:SetPoint("TOPRIGHT", value, "BOTTOMRIGHT", 0, -2) + margin:SetWidth(190) + margin:SetJustifyH("RIGHT") + frame.cost, frame.value, frame.margin = cost, value, margin + craft.frame = frame +end + +local function showReagentPrices(form, pricing) + for _, line in ipairs(craft.reagentLines) do line:Hide() end + for index, reagent in ipairs(pricing.reagents) do + local line = craft.reagentLines[index] + if not line then + line = form:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + craft.reagentLines[index] = line + end + line:ClearAllPoints() + line:SetPoint("TOPLEFT", form, "TOPLEFT", 190, -159 - ((index - 1) * 52)) + line:SetWidth(145) + line:SetJustifyH("LEFT") + if reagent.price then + if reagent.quantity > 1 then + line:SetText("|cff33ff99" .. money(reagent.price) .. " ea / " .. money(reagent.price * reagent.quantity) .. " total|r") + else + line:SetText("|cff33ff99" .. money(reagent.price) .. " each|r") + end + else + line:SetText("|cff888888N/A|r") + end + line:Show() + end +end + +updateCraftPriceUI = function() + local recipeID, form = selectedRecipe() + if not recipeID or not form or not form:IsShown() then hideCraft(); return end + local pricing = recipePricing(recipeID) + if not pricing then hideCraft(); return end + ensureCraftFrame(form) + craft.frame:Show() + craft.frame.cost:SetText(pricing.missing and "AH Craft Cost: |cffaaaaaaN/A|r" or "AH Craft Cost: " .. money(pricing.cost)) + if pricing.outputValue then + craft.frame.value:SetText("AH Result Value: " .. money(pricing.outputValue)) + if pricing.missing then + craft.frame.margin:SetText("") + else + local difference = pricing.outputValue - pricing.cost + if difference >= 0 then + craft.frame.margin:SetText("|cff33ff66Avg Profit / Craft: +" .. money(difference) .. "|r") + else + craft.frame.margin:SetText("|cffff5555Avg Loss / Craft: -" .. money(-difference) .. "|r") + end + end + else + craft.frame.value:SetText("AH Result Value: |cffaaaaaaN/A|r") + craft.frame.margin:SetText("") + end + showReagentPrices(form, pricing) + craft.recipeID = recipeID + craft.revision = cacheRevision +end + +local function status() + say(comma(countPrices()) .. " cached item prices.") + if browse.active then + say("browse scan: " .. comma(countSeen()) .. " summaries across " .. comma(browse.pages) .. " result pages.") + elseif DB.lastBrowseScan then + say("last browse scan: " .. comma(DB.lastBrowseItems or 0) .. " summaries, " .. age(DB.lastBrowseScan) .. " ago.") + end + if replicate.active then + say(replicate.waiting and "full scan: waiting for snapshot." or ("full scan: " .. comma(replicate.index) .. "/" .. comma(replicate.total) .. " auctions processed.")) + elseif DB.lastFullScan then + say("last full scan: " .. comma(DB.lastFullItems or 0) .. " items, " .. age(DB.lastFullScan) .. " ago.") + end +end + +SLASH_PROFESSIONPRICER1 = "/pp" +SLASH_PROFESSIONPRICER2 = "/pricer" +SlashCmdList.PROFESSIONPRICER = function(raw) + local command = (raw or ""):lower():match("^%s*(.-)%s*$") + if command == "scan" then + startBrowseScan() + elseif command == "fullscan" then + startFullScan() + elseif command == "status" then + status() + elseif command:match("^sort%s+") then + setSortMode(command:match("^sort%s+(%S+)$")) + else + say("/pp scan - browse the Auction House for prices") + say("/pp fullscan - request a full AH snapshot when available") + say("/pp status - show cached prices and scan progress") + say("/pp sort default|cost|profit - order recipes across the profession") + end +end + +app:SetScript("OnEvent", function(_, event, ...) + if event == "ADDON_LOADED" then + if ... ~= ADDON then return end + ProfessionPricerDB = ProfessionPricerDB or {} + DB = ProfessionPricerDB + DB.prices = DB.prices or {} + if not SORT_LABELS[DB.sortMode] then DB.sortMode = "default" end + ensureRecipeSort() + say("loaded. Use /pp help for commands.") + elseif event == "TRADE_SKILL_SHOW" or event == "TRADE_SKILL_LIST_UPDATE" or event == "TRADE_SKILL_DATA_SOURCE_CHANGED" then + C_Timer.After(0.1, function() ensureRecipeSort(); updateCraftPriceUI() end) + elseif event == "AUCTION_HOUSE_SHOW" then + C_Timer.After(0.1, createScanButton) + elseif event == "AUCTION_HOUSE_CLOSED" then + if browse.active then endBrowse("Auction House closed", false) end + elseif event == "AUCTION_HOUSE_BROWSE_RESULTS_UPDATED" or event == "AUCTION_HOUSE_BROWSE_RESULTS_ADDED" then + browseResultsUpdated() + elseif event == "COMMODITY_SEARCH_RESULTS_UPDATED" then + local itemID = ... + local price = lowestCommodityPrice(itemID) + if price then savePrice(itemID, price, "commodity") end + elseif event == "ITEM_SEARCH_RESULTS_UPDATED" then + local itemKey = ... + if itemKey and itemKey.itemID then + local price = lowestItemPrice(itemKey) + if price then savePrice(itemKey.itemID, price, "item") end + end + elseif event == "REPLICATE_ITEM_LIST_UPDATE" then + replicationUpdated() + end +end) + +local elapsedSinceCraftCheck = 0 +app:SetScript("OnUpdate", function(_, elapsed) + if not DB then return end + elapsedSinceCraftCheck = elapsedSinceCraftCheck + elapsed + if elapsedSinceCraftCheck < 0.5 then return end + elapsedSinceCraftCheck = 0 + ensureRecipeSort() + if DB.sortMode ~= "default" and recipeSort.scrollBox and recipeSort.revision ~= cacheRevision then + refreshSortedRecipes() + end + local recipeID, form = selectedRecipe() + if recipeID and form and form:IsShown() then + if recipeID ~= craft.recipeID or form ~= craft.owner or cacheRevision ~= craft.revision or not craft.frame or not craft.frame:IsShown() then + updateCraftPriceUI() + end + elseif craft.frame and craft.frame:IsShown() then + hideCraft() + end +end) + +app:RegisterEvent("ADDON_LOADED") +app:RegisterEvent("AUCTION_HOUSE_SHOW") +app:RegisterEvent("AUCTION_HOUSE_CLOSED") +app:RegisterEvent("AUCTION_HOUSE_BROWSE_RESULTS_UPDATED") +app:RegisterEvent("AUCTION_HOUSE_BROWSE_RESULTS_ADDED") +app:RegisterEvent("COMMODITY_SEARCH_RESULTS_UPDATED") +app:RegisterEvent("ITEM_SEARCH_RESULTS_UPDATED") +app:RegisterEvent("REPLICATE_ITEM_LIST_UPDATE") +app:RegisterEvent("TRADE_SKILL_SHOW") +app:RegisterEvent("TRADE_SKILL_LIST_UPDATE") +app:RegisterEvent("TRADE_SKILL_DATA_SOURCE_CHANGED") diff --git a/ProfessionPricer/ProfessionPricer.toc b/ProfessionPricer/ProfessionPricer.toc new file mode 100755 index 0000000..87f1601 --- /dev/null +++ b/ProfessionPricer/ProfessionPricer.toc @@ -0,0 +1,8 @@ +## Interface: 16001 +## Title: Profession Pricer +## Notes: Auction House price scanning and profession craft pricing for WoW Forever. +## Author: Local +## Version: 1.1 +## SavedVariables: ProfessionPricerDB + +ProfessionPricer.lua diff --git a/ProfessionPricer/README.md b/ProfessionPricer/README.md new file mode 100755 index 0000000..e9cd330 --- /dev/null +++ b/ProfessionPricer/README.md @@ -0,0 +1,13 @@ +# Profession Pricer + +Standalone Auction House price scanner and profession price display for WoW Forever / Classic Beta. Prices start empty and are saved in `ProfessionPricerDB` as they are learned. + +- Open the Auction House and click **Scan AH**, or use `/pp scan`, to browse all available item summaries. +- `/pp fullscan` requests a replicated auction snapshot when that client API is available. +- Normal Auction House browse and search results also update cached prices. +- Select a recipe in the profession window to see reagent prices, total cost, result value, and estimated profit or loss. Missing prices appear as `N/A`. +- Click **Sort** above the recipe list to cycle through the default category view, lowest AH craft cost, and highest estimated profit per craft. The price modes show one list across the whole profession. Unlearned recipes and recipes with missing prices follow priced learned recipes. +- `/pp sort default`, `/pp sort cost`, and `/pp sort profit` select a sort mode directly. The choice is saved. +- `/pp status` shows scan progress and cache size. `/pp help` lists commands. + +Auction prices are observed buyout prices, not guaranteed sale values. The display is an estimate and does not include Auction House fees or deposits.