initial commit

This commit is contained in:
Gitea
2020-11-13 14:13:12 -05:00
commit 05df49ff60
368 changed files with 128754 additions and 0 deletions

252
LibTSM/Core.lua Normal file
View File

@@ -0,0 +1,252 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
-- This is loaded before anything else and simply sets up the addon table
local ADDON_NAME, TSM = ...
local VERSION_RAW = GetAddOnMetadata("TradeSkillMaster", "Version")
local IS_DEV_VERSION = strmatch(VERSION_RAW, "^@tsm%-project%-version@$") and true or false
local private = {
context = {},
initOrder = {},
loadOrder = {},
frame = nil,
gotAddonLoaded = false,
gotPlayerLogin = false,
gotPlayerLogout = false,
}
-- ============================================================================
-- Module Metatable
-- ============================================================================
local MODULE_METHODS = {
OnModuleLoad = function(self, func)
local context = private.context[self]
assert(context and not context.moduleLoadFunc and not context.moduleLoadTime and type(func) == "function")
context.moduleLoadFunc = func
end,
OnSettingsLoad = function(self, func)
local context = private.context[self]
assert(context and not context.settingsLoadFunc and not context.settingsLoadTime and type(func) == "function")
context.settingsLoadFunc = func
end,
OnGameDataLoad = function(self, func)
local context = private.context[self]
assert(context and not context.gameDataLoadFunc and not context.gameDataLoadTime and type(func) == "function")
context.gameDataLoadFunc = func
end,
OnModuleUnload = function(self, func)
local context = private.context[self]
assert(context and not context.moduleUnloadFunc and not context.moduleUnloadTime and type(func) == "function")
context.moduleUnloadFunc = func
end,
}
local MODULE_MT = {
__index = MODULE_METHODS,
__newindex = function(self, key, value)
local context = private.context[self]
assert(context and not MODULE_METHODS[key] and not context.moduleLoadTime)
rawset(self, key, value)
end,
__metatable = false,
}
-- ============================================================================
-- Addon Object Functions
-- ============================================================================
function TSM.Init(path)
assert(type(path) == "string")
if private.context[path] then
error("Module already exists for path: "..tostring(path))
end
local module = setmetatable({}, MODULE_MT)
private.context[path] = {
path = path,
module = module,
moduleLoadFunc = nil,
moduleLoadTime = nil,
settingsLoadFunc = nil,
settingsLoadTime = nil,
gameDataLoadFunc = nil,
gameDataLoadTime = nil,
moduleUnloadFunc = nil,
moduleUnloadTime = nil,
}
-- store a reference to the context by both the module object and the path
private.context[module] = private.context[path]
tinsert(private.initOrder, path)
return module
end
function TSM.Include(path)
local context = private.context[path]
if not context then
error("Module doesn't exist for path: "..tostring(path))
end
private.ProcessModuleLoad(path)
return context.module
end
function TSM.IsDevVersion()
return IS_DEV_VERSION
end
function TSM.GetVersion()
return IS_DEV_VERSION and "Dev" or VERSION_RAW
end
function TSM.ModuleInfoIterator()
return private.ModuleInfoIterator, nil, 0
end
function TSM.IsWowClassic()
return WOW_PROJECT_ID == WOW_PROJECT_CLASSIC
end
function TSM.IsShadowlands()
return select(4, GetBuildInfo()) >= 90000
end
function TSM.DebugLogout()
private.UnloadAll()
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ModuleInfoIterator(_, index)
index = index + 1
local path = private.initOrder[index]
if not path then
return
end
local context = private.context[path]
assert(context)
return index, path, context.moduleLoadTime, context.settingsLoadTime, context.gameDataLoadTime, context.moduleUnloadTime
end
function private.ProcessModuleLoad(path)
local context = private.context[path]
assert(context)
if context.moduleLoadTime then
-- already loaded
return
end
tinsert(private.loadOrder, path)
context.moduleLoadTime = 0
if context.moduleLoadFunc then
local st = debugprofilestop()
context.moduleLoadFunc()
context.moduleLoadTime = debugprofilestop() - st
end
end
function private.ProcessSettingsLoad(path)
local context = private.context[path]
assert(context)
if context.settingsLoadTime then
-- already loaded
return
end
context.settingsLoadTime = 0
if context.settingsLoadFunc then
local st = debugprofilestop()
context.settingsLoadFunc()
context.settingsLoadTime = debugprofilestop() - st
end
end
function private.ProcessGameDataLoad(path)
local context = private.context[path]
assert(context)
if context.gameDataLoadTime then
-- already loaded
return
end
context.gameDataLoadTime = 0
if context.gameDataLoadFunc then
local st = debugprofilestop()
context.gameDataLoadFunc()
context.gameDataLoadTime = debugprofilestop() - st
end
end
function private.ProcessModuleUnload(path)
local context = private.context[path]
assert(context)
if context.moduleUnloadTime then
-- already unloaded
return
end
context.moduleUnloadTime = 0
if context.moduleUnloadFunc then
local st = debugprofilestop()
context.moduleUnloadFunc()
context.moduleUnloadTime = debugprofilestop() - st
end
end
function private.UnloadAll()
-- unload in the opposite order we loaded
for i = #private.loadOrder, 1, -1 do
private.ProcessModuleUnload(private.loadOrder[i])
end
end
function private.OnEvent(_, event, arg)
if event == "ADDON_LOADED" and arg == ADDON_NAME and not private.gotAddonLoaded then
assert(not private.gotAddonLoaded and not private.gotPlayerLogin and not private.gotPlayerLogout)
private.gotAddonLoaded = true
-- load any module which haven't already
for _, path in ipairs(private.initOrder) do
private.ProcessModuleLoad(path)
end
-- settings are now available
for _, path in ipairs(private.loadOrder) do
private.ProcessSettingsLoad(path)
end
private.frame:UnregisterEvent("ADDON_LOADED")
elseif event == "PLAYER_LOGIN" then
assert(private.gotAddonLoaded and not private.gotPlayerLogin and not private.gotPlayerLogout)
private.gotPlayerLogin = true
-- game data is now available
for _, path in ipairs(private.loadOrder) do
private.ProcessGameDataLoad(path)
end
elseif event == "PLAYER_LOGOUT" then
assert(private.gotAddonLoaded and not private.gotPlayerLogout)
private.gotPlayerLogout = true
if not private.gotPlayerLogin then
-- this can happen if the player exists the game during the loading screen, in which case we just ignore it
return
end
private.UnloadAll()
end
end
-- ============================================================================
-- Initialization Code
-- ============================================================================
do
private.frame = CreateFrame("Frame")
private.frame:RegisterEvent("ADDON_LOADED")
private.frame:RegisterEvent("PLAYER_LOGIN")
private.frame:RegisterEvent("PLAYER_LOGOUT")
private.frame:SetScript("OnEvent", private.OnEvent)
end

3284
LibTSM/Data/BonusIds.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ClassicRealms = TSM.Init("Data.ClassicRealms")
-- ============================================================================
-- Classic Realm Data
-- ============================================================================
-- Generated with the following query:
-- SELECT TRIM(TRAILING '-Alliance' FROM r.localizedName) as localizedName, g.value
-- FROM RealmsClassic r INNER JOIN RegionsClassic g ON g.id = r.regionId
-- WHERE r.localizedName LIKE '%-Alliance';
local CLASSIC_REALM_INFO = {
["Amnennar"] = { region = "EU" },
["Ashbringer"] = { region = "EU" },
["Auberdine"] = { region = "EU" },
["Bloodfang"] = { region = "EU" },
["Хроми"] = { region = "EU" },
["Dragon's Call"] = { region = "EU" },
["Dreadmist"] = { region = "EU" },
["Everlook"] = { region = "EU" },
["Finkle"] = { region = "EU" },
["Firemaw"] = { region = "EU" },
["Пламегор"] = { region = "EU" },
["Flamelash"] = { region = "EU" },
["Gandling"] = { region = "EU" },
["Gehennas"] = { region = "EU" },
["Golemagg"] = { region = "EU" },
["Hydraxian Waterlords"] = { region = "EU" },
["Judgement"] = { region = "EU" },
["Lakeshire"] = { region = "EU" },
["Lucifron"] = { region = "EU" },
["Mirage Raceway"] = { region = "EU" },
["Mograine"] = { region = "EU" },
["Nethergarde Keep"] = { region = "EU" },
["Noggenfogger"] = { region = "EU" },
["Patchwerk"] = { region = "EU" },
["Pyrewood Village"] = { region = "EU" },
["Razorfen"] = { region = "EU" },
["Razorgore"] = { region = "EU" },
["Рок-Делар"] = { region = "EU" },
["Shazzrah"] = { region = "EU" },
["Skullflame"] = { region = "EU" },
["Stonespine"] = { region = "EU" },
["Sulfuron"] = { region = "EU" },
["Ten Storms"] = { region = "EU" },
["Transcendence"] = { region = "EU" },
["Venoxis"] = { region = "EU" },
["Змейталак"] = { region = "EU" },
["Zandalar Tribe"] = { region = "EU" },
["Dragonfang"] = { region = "EU" },
["Earthshaker"] = { region = "EU" },
["Heartstriker"] = { region = "EU" },
["Вестник Рока"] = { region = "EU" },
["Mandokir"] = { region = "EU" },
["Anathema"] = { region = "US" },
["Arugal"] = { region = "US" },
["Ashkandi"] = { region = "US" },
["Atiesh"] = { region = "US" },
["Azuresong"] = { region = "US" },
["Benediction"] = { region = "US" },
["Bigglesworth"] = { region = "US" },
["Blaumeux"] = { region = "US" },
["Bloodsail Buccaneers"] = { region = "US" },
["Deviate Delight"] = { region = "US" },
["Faerlina"] = { region = "US" },
["Fairbanks"] = { region = "US" },
["Felstriker"] = { region = "US" },
["Grobbulus"] = { region = "US" },
["Herod"] = { region = "US" },
["Incendius"] = { region = "US" },
["Kirtonos"] = { region = "US" },
["Kromcrush"] = { region = "US" },
["Kurinnaxx"] = { region = "US" },
["Mankrik"] = { region = "US" },
["Myzrael"] = { region = "US" },
["Netherwind"] = { region = "US" },
["Old Blanchy"] = { region = "US" },
["Pagle"] = { region = "US" },
["Rattlegore"] = { region = "US" },
["Remulos"] = { region = "US" },
["Skeram"] = { region = "US" },
["Smolderweb"] = { region = "US" },
["Stalagg"] = { region = "US" },
["Sulfuras"] = { region = "US" },
["Thalnos"] = { region = "US" },
["Thunderfury"] = { region = "US" },
["Westfall"] = { region = "US" },
["Whitemane"] = { region = "US" },
["Windseeker"] = { region = "US" },
["Yojamba"] = { region = "US" },
["Arcanite Reaper"] = { region = "US" },
["Earthfury"] = { region = "US" },
["Heartseeker"] = { region = "US" },
["Loatheb"] = { region = "US" },
["Sul'thraze"] = { region = "US" },
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function ClassicRealms.GetRegion(realmName)
local info = CLASSIC_REALM_INFO[realmName]
return info and info.region or nil
end

File diff suppressed because it is too large Load Diff

88
LibTSM/Data/FontPaths.lua Normal file
View File

@@ -0,0 +1,88 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local FontPaths = TSM.Init("Data.FontPaths")
local ALPHABET_LOOKUP = {
enUS = "roman",
esES = "roman",
esMX = "roman",
deDE = "roman",
frFR = "roman",
itIT = "roman",
ptBR = "roman",
koKR = "korean",
zhCN = "chinese",
zhTW = "chinese",
ruRU = "russian",
}
local ALPHABET = ALPHABET_LOOKUP[GetLocale()]
assert(ALPHABET)
-- ============================================================================
-- Font Path Data
-- ============================================================================
local FONT_PATHS = {
BodyRegular = {
roman = "Interface\\Addons\\TradeSkillMaster\\Media\\Montserrat-Regular.ttf",
korean = "Fonts\\2002.ttf",
chinese = "Fonts\\ARKai_C.ttf",
russian = "Interface\\Addons\\TradeSkillMaster\\Media\\Montserrat-Regular.ttf",
},
BodyMedium = {
roman = "Interface\\Addons\\TradeSkillMaster\\Media\\Montserrat-Medium.ttf",
korean = "Fonts\\2002.ttf",
chinese = "Fonts\\ARKai_C.ttf",
russian = "Interface\\Addons\\TradeSkillMaster\\Media\\Montserrat-Medium.ttf",
},
BodyBold = {
roman = "Interface\\Addons\\TradeSkillMaster\\Media\\Montserrat-Bold.ttf",
korean = "Fonts\\2002.ttf",
chinese = "Fonts\\ARKai_C.ttf",
russian = "Interface\\Addons\\TradeSkillMaster\\Media\\Montserrat-Bold.ttf",
},
Item = {
roman = "Fonts\\FRIZQT__.ttf",
korean = "Fonts\\2002.ttf",
chinese = "Fonts\\ARKai_C.ttf",
russian = "Fonts\\FRIZQT___CYR.ttf",
},
Table = {
roman = "Interface\\Addons\\TradeSkillMaster\\Media\\Roboto-Medium.ttf",
korean = "Fonts\\2002.ttf",
chinese = "Fonts\\ARKai_C.ttf",
russian = "Interface\\Addons\\TradeSkillMaster\\Media\\Roboto-Medium.ttf",
},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function FontPaths.GetBodyRegular()
return FONT_PATHS.BodyRegular[ALPHABET]
end
function FontPaths.GetBodyMedium()
return FONT_PATHS.BodyMedium[ALPHABET]
end
function FontPaths.GetBodyBold()
return FONT_PATHS.BodyBold[ALPHABET]
end
function FontPaths.GetItem()
return FONT_PATHS.Item[ALPHABET]
end
function FontPaths.GetTable()
return FONT_PATHS.Table[ALPHABET]
end

137
LibTSM/Data/ItemClass.lua Normal file
View File

@@ -0,0 +1,137 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ItemClass = TSM.Init("Data.ItemClass")
local STATIC_DATA = {
classes = {},
subClasses = {},
classLookup = {},
classIdLookup = {},
inventorySlotIdLookup = {},
}
-- ============================================================================
-- Population of the Static Data
-- ============================================================================
do
-- Needed because NUM_LE_ITEM_CLASSS contains an erroneous value
local ITEM_CLASS_IDS = nil
if not TSM.IsWowClassic() then
ITEM_CLASS_IDS = {
LE_ITEM_CLASS_WEAPON,
LE_ITEM_CLASS_ARMOR,
LE_ITEM_CLASS_CONTAINER,
LE_ITEM_CLASS_GEM,
LE_ITEM_CLASS_ITEM_ENHANCEMENT,
LE_ITEM_CLASS_CONSUMABLE,
LE_ITEM_CLASS_GLYPH,
LE_ITEM_CLASS_TRADEGOODS,
LE_ITEM_CLASS_RECIPE,
LE_ITEM_CLASS_BATTLEPET,
LE_ITEM_CLASS_QUESTITEM,
LE_ITEM_CLASS_MISCELLANEOUS,
}
else
ITEM_CLASS_IDS = {
LE_ITEM_CLASS_WEAPON,
LE_ITEM_CLASS_ARMOR,
LE_ITEM_CLASS_CONTAINER,
LE_ITEM_CLASS_CONSUMABLE,
LE_ITEM_CLASS_TRADEGOODS,
LE_ITEM_CLASS_PROJECTILE,
LE_ITEM_CLASS_QUIVER,
LE_ITEM_CLASS_RECIPE,
LE_ITEM_CLASS_REAGENT,
LE_ITEM_CLASS_MISCELLANEOUS,
}
end
for _, classId in ipairs(ITEM_CLASS_IDS) do
local class = GetItemClassInfo(classId)
if class then
STATIC_DATA.classIdLookup[strlower(class)] = classId
STATIC_DATA.classLookup[class] = {}
STATIC_DATA.classLookup[class]._index = classId
local subClasses = nil
if TSM.IsWowClassic() then
subClasses = {GetAuctionItemSubClasses(classId)}
else
subClasses = C_AuctionHouse.GetAuctionItemSubClasses(classId)
end
for _, subClassId in pairs(subClasses) do
local subClassName = GetItemSubClassInfo(classId, subClassId)
if not strfind(subClassName, "(OBSOLETE)") then
STATIC_DATA.classLookup[class][subClassName] = subClassId
end
end
end
end
for class, subClasses in pairs(STATIC_DATA.classLookup) do
tinsert(STATIC_DATA.classes, class)
STATIC_DATA.subClasses[class] = {}
for subClass in pairs(subClasses) do
if subClass ~= "_index" then
tinsert(STATIC_DATA.subClasses[class], subClass)
end
end
sort(STATIC_DATA.subClasses[class], function(a, b) return STATIC_DATA.classLookup[class][a] < STATIC_DATA.classLookup[class][b] end)
end
sort(STATIC_DATA.classes, function(a, b) return STATIC_DATA.classIdLookup[strlower(a)] < STATIC_DATA.classIdLookup[strlower(b)] end)
if TSM.IsShadowlands() then
for _, id in pairs(Enum.InventoryType) do
local invType = GetItemInventorySlotInfo(id)
if invType then
STATIC_DATA.inventorySlotIdLookup[strlower(invType)] = id
end
end
else
for i = 0, NUM_LE_INVENTORY_TYPES do
local invType = GetItemInventorySlotInfo(i)
if invType then
STATIC_DATA.inventorySlotIdLookup[strlower(invType)] = i
end
end
end
end
-- ============================================================================
-- Module Functions
-- ============================================================================
function ItemClass.GetClasses()
return STATIC_DATA.classes
end
function ItemClass.GetSubClasses(class)
return STATIC_DATA.subClasses[class]
end
function ItemClass.GetClassIdFromClassString(classStr)
return STATIC_DATA.classIdLookup[strlower(classStr)]
end
function ItemClass.GetSubClassIdFromSubClassString(subClass, classId)
if not classId then return end
local class = GetItemClassInfo(classId)
if not STATIC_DATA.classLookup[class] then return end
for str, index in pairs(STATIC_DATA.classLookup[class]) do
if strlower(str) == strlower(subClass) then
return index
end
end
end
function ItemClass.GetInventorySlotIdFromInventorySlotString(slot)
return STATIC_DATA.inventorySlotIdLookup[strlower(slot)]
end

255
LibTSM/Data/Mill.lua Normal file
View File

@@ -0,0 +1,255 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Mill = TSM.Init("Data.Mill")
local private = {}
-- ============================================================================
-- Mill Data
-- ============================================================================
local DATA = TSM.IsWowClassic() and {} or {
-- ======================================= Common Pigments =======================================
["i:39151"] = { -- Alabaster Pigment (Ivory / Moonglow Ink)
["i:765"] = 0.5,
["i:2447"] = 0.5,
["i:2449"] = 0.6,
},
["i:39343"] = { -- Azure Pigment (Ink of the Sea)
["i:39969"] = 0.5,
["i:36904"] = 0.5,
["i:36907"] = 0.5,
["i:36901"] = 0.5,
["i:39970"] = 0.5,
["i:37921"] = 0.5,
["i:36905"] = 0.6,
["i:36906"] = 0.6,
["i:36903"] = 0.6,
},
["i:61979"] = { -- Ashen Pigment (Blackfallow Ink)
["i:52983"] = 0.5,
["i:52984"] = 0.5,
["i:52985"] = 0.5,
["i:52986"] = 0.5,
["i:52987"] = 0.6,
["i:52988"] = 0.6,
},
["i:39334"] = { -- Dusky Pigment (Midnight Ink)
["i:785"] = 0.5,
["i:2450"] = 0.5,
["i:2452"] = 0.5,
["i:2453"] = 0.6,
["i:3820"] = 0.6,
},
["i:39339"] = { -- Emerald Pigment (Jadefire Ink)
["i:3818"] = 0.5,
["i:3821"] = 0.5,
["i:3358"] = 0.6,
["i:3819"] = 0.6,
},
["i:39338"] = { -- Golden Pigment (Lion's Ink)
["i:3355"] = 0.5,
["i:3369"] = 0.5,
["i:3356"] = 0.6,
["i:3357"] = 0.6,
},
["i:39342"] = { -- Nether Pigment (Ethereal Ink)
["i:22785"] = 0.5,
["i:22786"] = 0.5,
["i:22787"] = 0.5,
["i:22789"] = 0.5,
["i:22790"] = 0.6,
["i:22791"] = 0.6,
["i:22792"] = 0.6,
["i:22793"] = 0.6,
},
["i:79251"] = { -- Shadow Pigment (Ink of Dreams)
["i:72237"] = 0.5,
["i:72234"] = 0.5,
["i:79010"] = 0.5,
["i:72235"] = 0.5,
["i:89639"] = 0.5,
["i:79011"] = 0.6,
},
["i:39341"] = { -- Silvery Pigment (Shimmering Ink)
["i:13463"] = 0.5,
["i:13464"] = 0.5,
["i:13465"] = 0.6,
["i:13466"] = 0.6,
["i:13467"] = 0.6,
},
["i:39340"] = { -- Violet Pigment (Celestial Ink)
["i:4625"] = 0.5,
["i:8831"] = 0.5,
["i:8838"] = 0.5,
["i:8839"] = 0.6,
["i:8845"] = 0.6,
["i:8846"] = 0.6,
},
["i:114931"] = { -- Cerulean Pigment (Warbinder's Ink)
["i:109124"] = 0.42,
["i:109125"] = 0.42,
["i:109126"] = 0.42,
["i:109127"] = 0.42,
["i:109128"] = 0.42,
["i:109129"] = 0.42,
},
["i:129032"] = { -- Roseate Pigment (No Legion Ink)
["i:124101"] = 0.42,
["i:124102"] = 0.42,
["i:124103"] = 0.42,
["i:124104"] = 0.47,
["i:124105"] = 1.22,
["i:124106"] = 0.42,
["i:128304"] = 0.2,
["i:151565"] = 0.43,
},
-- ======================================= Rare Pigments =======================================
["i:43109"] = { -- Icy Pigment (Snowfall Ink)
["i:39969"] = 0.05,
["i:36904"] = 0.05,
["i:36907"] = 0.05,
["i:36901"] = 0.05,
["i:39970"] = 0.05,
["i:37921"] = 0.05,
["i:36905"] = 0.1,
["i:36906"] = 0.1,
["i:36903"] = 0.1,
},
["i:61980"] = { -- Burning Embers (Inferno Ink)
["i:52983"] = 0.05,
["i:52984"] = 0.05,
["i:52985"] = 0.05,
["i:52986"] = 0.05,
["i:52987"] = 0.1,
["i:52988"] = 0.1,
},
["i:43104"] = { -- Burnt Pigment (Dawnstar Ink)
["i:3356"] = 0.1,
["i:3357"] = 0.1,
["i:3369"] = 0.05,
["i:3355"] = 0.05,
},
["i:43108"] = { -- Ebon Pigment (Darkflame Ink)
["i:22792"] = 0.1,
["i:22790"] = 0.1,
["i:22791"] = 0.1,
["i:22793"] = 0.1,
["i:22786"] = 0.05,
["i:22785"] = 0.05,
["i:22787"] = 0.05,
["i:22789"] = 0.05,
},
["i:43105"] = { -- Indigo Pigment (Royal Ink)
["i:3358"] = 0.1,
["i:3819"] = 0.1,
["i:3821"] = 0.05,
["i:3818"] = 0.05,
},
["i:79253"] = { -- Misty Pigment (Starlight Ink)
["i:72237"] = 0.05,
["i:72234"] = 0.05,
["i:79010"] = 0.05,
["i:72235"] = 0.05,
["i:79011"] = 0.1,
["i:89639"] = 0.05,
},
["i:43106"] = { -- Ruby Pigment (Fiery Ink)
["i:4625"] = 0.05,
["i:8838"] = 0.05,
["i:8831"] = 0.05,
["i:8845"] = 0.1,
["i:8846"] = 0.1,
["i:8839"] = 0.1,
},
["i:43107"] = { -- Sapphire Pigment (Ink of the Sky)
["i:13463"] = 0.05,
["i:13464"] = 0.05,
["i:13465"] = 0.1,
["i:13466"] = 0.1,
["i:13467"] = 0.1,
},
["i:43103"] = { -- Verdant Pigment (Hunter's Ink)
["i:2453"] = 0.1,
["i:3820"] = 0.1,
["i:2450"] = 0.05,
["i:785"] = 0.05,
["i:2452"] = 0.05,
},
["i:129034"] = { -- Sallow Pigment (No Legion Ink)
["i:124101"] = 0.04,
["i:124102"] = 0.04,
["i:124103"] = 0.05,
["i:124104"] = 0.05,
["i:124105"] = 0.04,
["i:124106"] = 2.14,
["i:128304"] = 0.0018,
["i:151565"] = 0.048,
},
-- ======================================= BFA Pigments ========================================
["i:153669"] = { -- Viridescent Pigment
["i:152505"] = 0.1325,
["i:152506"] = 0.1325,
["i:152507"] = 0.1325,
["i:152508"] = 0.1325,
["i:152509"] = 0.1325,
["i:152511"] = 0.1325,
["i:152510"] = 0.325,
},
["i:153636"] = { -- Crimson Pigment
["i:152505"] = 0.315,
["i:152506"] = 0.315,
["i:152507"] = 0.315,
["i:152508"] = 0.315,
["i:152509"] = 0.315,
["i:152511"] = 0.315,
["i:152510"] = 0.315,
},
["i:153635"] = { -- Ultramarine Pigment
["i:152505"] = 0.825,
["i:152506"] = 0.825,
["i:152507"] = 0.825,
["i:152508"] = 0.825,
["i:152509"] = 0.825,
["i:152511"] = 0.825,
["i:152510"] = 0.825,
},
["i:168662"] = { -- Maroon Pigment
["i:168487"] = 0.6,
},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function Mill.TargetItemIterator()
return private.TableKeyIterator, DATA, nil
end
function Mill.SourceItemIterator(targetItemString)
return private.TableKeyIterator, DATA[targetItemString], nil
end
function Mill.GetRate(targetItemString, sourceItemString)
return DATA[targetItemString][sourceItemString]
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.TableKeyIterator(tbl, index)
index = next(tbl, index)
return index
end

View File

@@ -0,0 +1,605 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ProfessionInfo = TSM.Init("Data.ProfessionInfo")
-- ============================================================================
-- Profession Info Data
-- ============================================================================
local PROFESSION_NAMES = {
Mining = GetSpellInfo(2575),
Smelting = GetSpellInfo(2656),
Poisons = GetSpellInfo(2842),
}
local CLASSIC_SUB_NAMES = {
[APPRENTICE] = true,
[JOURNEYMAN] = true,
[EXPERT] = true,
[ARTISAN] = true,
["大师级"] = true, -- zhCN ARTISAN
["Мастеровой"] = true, -- ruRU ARTISAN
}
local VELLUM_ITEM_STRING = "i:38682"
local ENGINEERING_TINKERS = {
[54736] = true, -- Engineering: EMP Generator
[54793] = true, -- Engineering: Frag Belt
[55002] = true, -- Engineering: Flexweave Underlay
[55016] = true, -- Engineering: Nitro Boosts
[67839] = true, -- Engineering: Mind Amplification Dish
[82200] = true, -- Engineering: Spinal healing Injector
[84424] = true, -- Engineering: Invisibility Field
[84425] = true, -- Engineering: Cardboard Assasin
[84427] = true, -- Engineering: Grounded Plasma Shield
[109099] = true, -- Engineering: Watergliding Jets
[126392] = true, -- Engineering: Goblin Glider
}
local MASS_MILLING_RECIPES = {
[190381] = "i:114931", -- Frostweed
[190382] = "i:114931", -- Fireweed
[190383] = "i:114931", -- Gorgrond Flytrap
[190384] = "i:114931", -- Starflower
[190385] = "i:114931", -- Nargrand Arrowbloom
[190386] = "i:114931", -- Talador Orchid
[209658] = "i:129032", -- Aethril
[209659] = "i:129032", -- Dreamleaf
[209660] = "i:129032", -- Foxflower
[209661] = "i:129032", -- Fjarnskaggl
[209662] = "i:129032", -- Starlight Rose
[209664] = "i:129034", -- Felwort
[210116] = "i:129032", -- Yseralline Seeds
[247861] = "i:129034", -- Astral Glory
}
local ENCHANTING_RECIPIES = {
-- Scraped from Wowhead (http://www.wowhead.com/items/consumables/item-enhancements-permanent?filter=86;4;0) using the following javascript:
-- for (i=0; i<listviewitems.length; i++) console.log("["+listviewitems[i].sourcemore[0].ti+"] = \"i:"+listviewitems[i].id+"\", -- "+listviewitems[i].name.substr(1));
[298009] = "i:168446", -- Enchant Ring - Accord of Critical Strike Rank 1
[298010] = "i:168446", -- Enchant Ring - Accord of Critical Strike Rank 2
[298011] = "i:168446", -- Enchant Ring - Accord of Critical Strike Rank 3
[297989] = "i:168447", -- Enchant Ring - Accord of Haste Rank 1
[297994] = "i:168447", -- Enchant Ring - Accord of Haste Rank 2
[298016] = "i:168447", -- Enchant Ring - Accord of Haste Rank 3
[297995] = "i:168448", -- Enchant Ring - Accord of Mastery Rank 1
[298001] = "i:168448", -- Enchant Ring - Accord of Mastery Rank 2
[298002] = "i:168448", -- Enchant Ring - Accord of Mastery Rank 3
[297993] = "i:168449", -- Enchant Ring - Accord of Versatility Rank 1
[297991] = "i:168449", -- Enchant Ring - Accord of Versatility Rank 2
[297999] = "i:168449", -- Enchant Ring - Accord of Versatility Rank 3
[298440] = "i:168596", -- Enchant Weapon - Force Multiplier Rank 1
[298439] = "i:168596", -- Enchant Weapon - Force Multiplier Rank 2
[300788] = "i:168596", -- Enchant Weapon - Force Multiplier Rank 3
[298433] = "i:168593", -- Enchant Weapon - Machinist's Brilliance Rank 1
[300769] = "i:168593", -- Enchant Weapon - Machinist's Brilliance Rank 2
[300770] = "i:168593", -- Enchant Weapon - Machinist's Brilliance Rank 3
[298442] = "i:168598", -- Enchant Weapon - Naga Hide Rank 1
[298441] = "i:168598", -- Enchant Weapon - Naga Hide Rank 2
[300789] = "i:168598", -- Enchant Weapon - Naga Hide Rank 3
[298438] = "i:168592", -- Enchant Weapon - Oceanic Restoration Rank 1
[298437] = "i:168592", -- Enchant Weapon - Oceanic Restoration Rank 2
[298515] = "i:168592", -- Enchant Weapon - Oceanic Restoration Rank 3
[255075] = "i:153442", -- Enchant Ring - Pact of Critical Strike Rank 1
[255090] = "i:153442", -- Enchant Ring - Pact of Critical Strike Rank 2
[255098] = "i:153442", -- Enchant Ring - Pact of Critical Strike Rank 3
[255076] = "i:153443", -- Enchant Ring - Pact of Haste Rank 1
[255091] = "i:153443", -- Enchant Ring - Pact of Haste Rank 2
[255099] = "i:153443", -- Enchant Ring - Pact of Haste Rank 3
[255077] = "i:153444", -- Enchant Ring - Pact of Mastery Rank 1
[255092] = "i:153444", -- Enchant Ring - Pact of Mastery Rank 2
[255100] = "i:153444", -- Enchant Ring - Pact of Mastery Rank 3
[255078] = "i:153445", -- Enchant Ring - Pact of Versatility Rank 1
[255093] = "i:153445", -- Enchant Ring - Pact of Versatility Rank 2
[255101] = "i:153445", -- Enchant Ring - Pact of Versatility Rank 3
[255071] = "i:153438", -- Enchant Ring - Seal of Critical Strike Rank 1
[255086] = "i:153438", -- Enchant Ring - Seal of Critical Strike Rank 2
[255094] = "i:153438", -- Enchant Ring - Seal of Critical Strike Rank 3
[255072] = "i:153439", -- Enchant Ring - Seal of Haste Rank 1
[255087] = "i:153439", -- Enchant Ring - Seal of Haste Rank 2
[255095] = "i:153439", -- Enchant Ring - Seal of Haste Rank 3
[255073] = "i:153440", -- Enchant Ring - Seal of Mastery Rank 1
[255088] = "i:153440", -- Enchant Ring - Seal of Mastery Rank 2
[255096] = "i:153440", -- Enchant Ring - Seal of Mastery Rank 3
[255074] = "i:153441", -- Enchant Ring - Seal of Versatility Rank 1
[255089] = "i:153441", -- Enchant Ring - Seal of Versatility Rank 2
[255097] = "i:153441", -- Enchant Ring - Seal of Versatility Rank 3
[255103] = "i:153476", -- Enchant Weapon - Coastal Surge Rank 1
[255104] = "i:153476", -- Enchant Weapon - Coastal Surge Rank 2
[255105] = "i:153476", -- Enchant Weapon - Coastal Surge Rank 3
[255110] = "i:153478", -- Enchant Weapon - Siphoning Rank 1
[255111] = "i:153478", -- Enchant Weapon - Siphoning Rank 2
[255112] = "i:153478", -- Enchant Weapon - Siphoning Rank 3
[255141] = "i:153480", -- Enchant Weapon - Gale-Force Striking Rank 1
[255142] = "i:153480", -- Enchant Weapon - Gale-Force Striking Rank 2
[255143] = "i:153480", -- Enchant Weapon - Gale-Force Striking Rank 3
[268907] = "i:159785", -- Enchant Weapon - Deadly Navigation Rank 1
[268908] = "i:159785", -- Enchant Weapon - Deadly Navigation Rank 2
[268909] = "i:159785", -- Enchant Weapon - Deadly Navigation Rank 3
[268894] = "i:159786", -- Enchant Weapon - Quick Navigation Rank 1
[268895] = "i:159786", -- Enchant Weapon - Quick Navigation Rank 2
[268897] = "i:159786", -- Enchant Weapon - Quick Navigation Rank 3
[268901] = "i:159787", -- Enchant Weapon - Masterful Navigation Rank 1
[268902] = "i:159787", -- Enchant Weapon - Masterful Navigation Rank 2
[268903] = "i:159787", -- Enchant Weapon - Masterful Navigation Rank 3
[268852] = "i:159788", -- Enchant Weapon - Versatile Navigation Rank 1
[268878] = "i:159788", -- Enchant Weapon - Versatile Navigation Rank 2
[268879] = "i:159788", -- Enchant Weapon - Versatile Navigation Rank 3
[268913] = "i:159789", -- Enchant Weapon - Stalwart Navigation Rank 1
[268914] = "i:159789", -- Enchant Weapon - Stalwart Navigation Rank 2
[268915] = "i:159789", -- Enchant Weapon - Stalwart Navigation Rank 3
[255129] = "i:153479", -- Enchant Weapon - Torrent of Elements Rank 1
[255130] = "i:153479", -- Enchant Weapon - Torrent of Elements Rank 2
[255131] = "i:153479", -- Enchant Weapon - Torrent of Elements Rank 3
[255035] = "i:153430", -- Enchant Gloves - Kul Tiran Herbalism
[255040] = "i:153431", -- Enchant Gloves - Kul Tiran Mining
[255065] = "i:153434", -- Enchant Gloves - Kul Tiran Skinning
[255070] = "i:153437", -- Enchant Gloves - Kul Tiran Crafting
[267458] = "i:159464", -- Enchant Gloves - Zandalari Herbalism
[267482] = "i:159466", -- Enchant Gloves - Zandalari Mining
[267486] = "i:159467", -- Enchant Gloves - Zandalari Skinning
[267498] = "i:159471", -- Enchant Gloves - Zandalari Crafting
[123125] = "i:141910", -- Enchant Neck - Mark of the Ancient Priestess
[255066] = "i:153435", -- Enchant Gloves - Kul Tiran Surveying
[267490] = "i:159468", -- Enchant Gloves - Zandalari Surveying
[158914] = "i:110638", -- Enchant Ring - Gift of Critical Strike
[158915] = "i:110639", -- Enchant Ring - Gift of Haste
[158916] = "i:110640", -- Enchant Ring - Gift of Mastery
[158918] = "i:110642", -- Enchant Ring - Gift of Versatility
[158899] = "i:110645", -- Enchant Neck - Gift of Critical Strike
[158900] = "i:110646", -- Enchant Neck - Gift of Haste
[158901] = "i:110647", -- Enchant Neck - Gift of Mastery
[158903] = "i:110649", -- Enchant Neck - Gift of Versatility
[158884] = "i:110652", -- Enchant Cloak - Gift of Critical Strike
[158885] = "i:110653", -- Enchant Cloak - Gift of Haste
[158886] = "i:110654", -- Enchant Cloak - Gift of Mastery
[158889] = "i:110656", -- Enchant Cloak - Gift of Versatility
[159235] = "i:110682", -- Enchant Weapon - Mark of the Thunderlord
[159236] = "i:112093", -- Enchant Weapon - Mark of the Shattered Hand
[159673] = "i:112115", -- Enchant Weapon - Mark of Shadowmoon
[159674] = "i:112160", -- Enchant Weapon - Mark of Blackrock
[159671] = "i:112164", -- Enchant Weapon - Mark of Warsong
[159672] = "i:112165", -- Enchant Weapon - Mark of the Frostwolf
[173323] = "i:118015", -- Enchant Weapon - Mark of Bleeding Hollow
[158907] = "i:110617", -- Enchant Ring - Breath of Critical Strike
[158908] = "i:110618", -- Enchant Ring - Breath of Haste
[158909] = "i:110619", -- Enchant Ring - Breath of Mastery
[158911] = "i:110621", -- Enchant Ring - Breath of Versatility
[158892] = "i:110624", -- Enchant Neck - Breath of Critical Strike
[158893] = "i:110625", -- Enchant Neck - Breath of Haste
[158894] = "i:110626", -- Enchant Neck - Breath of Mastery
[158896] = "i:110628", -- Enchant Neck - Breath of Versatility
[158877] = "i:110631", -- Enchant Cloak - Breath of Critical Strike
[158878] = "i:110632", -- Enchant Cloak - Breath of Haste
[158879] = "i:110633", -- Enchant Cloak - Breath of Mastery
[158881] = "i:110635", -- Enchant Cloak - Breath of Versatility
[104425] = "i:74723", -- Enchant Weapon - Windsong
[104427] = "i:74724", -- Enchant Weapon - Jade Spirit
[104430] = "i:74725", -- Enchant Weapon - Elemental Force
[104434] = "i:74726", -- Enchant Weapon - Dancing Steel
[104440] = "i:74727", -- Enchant Weapon - Colossus
[104442] = "i:74728", -- Enchant Weapon - River's Song
[104338] = "i:74700", -- Enchant Bracer - Mastery
[104385] = "i:74701", -- Enchant Bracer - Major Dodge
[104389] = "i:74703", -- Enchant Bracer - Super Intellect
[104390] = "i:74704", -- Enchant Bracer - Exceptional Strength
[104391] = "i:74705", -- Enchant Bracer - Greater Agility
[104392] = "i:74706", -- Enchant Chest - Super Resilience
[104393] = "i:74707", -- Enchant Chest - Mighty Versatility
[104395] = "i:74708", -- Enchant Chest - Glorious Stats
[104397] = "i:74709", -- Enchant Chest - Superior Stamina
[104398] = "i:74710", -- Enchant Cloak - Accuracy
[104401] = "i:74711", -- Enchant Cloak - Greater Protection
[104403] = "i:74712", -- Enchant Cloak - Superior Intellect
[104404] = "i:74713", -- Enchant Cloak - Superior Critical Strike
[104407] = "i:74715", -- Enchant Boots - Greater Haste
[104408] = "i:74716", -- Enchant Boots - Greater Precision
[104409] = "i:74717", -- Enchant Boots - Blurred Speed
[104414] = "i:74718", -- Enchant Boots - Pandaren's Step
[104416] = "i:74719", -- Enchant Gloves - Greater Haste
[104417] = "i:74720", -- Enchant Gloves - Superior Haste
[104419] = "i:74721", -- Enchant Gloves - Super Strength
[104420] = "i:74722", -- Enchant Gloves - Superior Mastery
[104445] = "i:74729", -- Enchant Off-Hand - Major Intellect
[130758] = "i:89737", -- Enchant Shield - Greater Parry
[74195] = "i:52747", -- Enchant Weapon - Mending
[96264] = "i:68784", -- Enchant Bracer - Agility
[96261] = "i:68785", -- Enchant Bracer - Major Strength
[96262] = "i:68786", -- Enchant Bracer - Mighty Intellect
[74132] = "i:52687", -- Enchant Gloves - Mastery
[74189] = "i:52743", -- Enchant Boots - Earthen Vitality
[74191] = "i:52744", -- Enchant Chest - Mighty Stats
[74192] = "i:52745", -- Enchant Cloak - Lesser Power
[74193] = "i:52746", -- Enchant Bracer - Speed
[74197] = "i:52748", -- Enchant Weapon - Avalanche
[74198] = "i:52749", -- Enchant Gloves - Haste
[74199] = "i:52750", -- Enchant Boots - Haste
[74200] = "i:52751", -- Enchant Chest - Stamina
[74201] = "i:52752", -- Enchant Bracer - Critical Strike
[74202] = "i:52753", -- Enchant Cloak - Intellect
[74207] = "i:52754", -- Enchant Shield - Protection
[74211] = "i:52755", -- Enchant Weapon - Elemental Slayer
[74212] = "i:52756", -- Enchant Gloves - Exceptional Strength
[74213] = "i:52757", -- Enchant Boots - Major Agility
[74214] = "i:52758", -- Enchant Chest - Mighty Resilience
[74220] = "i:52759", -- Enchant Gloves - Greater Haste
[74223] = "i:52760", -- Enchant Weapon - Hurricane
[74225] = "i:52761", -- Enchant Weapon - Heartsong
[74226] = "i:52762", -- Enchant Shield - Mastery
[74229] = "i:52763", -- Enchant Bracer - Superior Dodge
[74230] = "i:52764", -- Enchant Cloak - Critical Strike
[74231] = "i:52765", -- Enchant Chest - Exceptional Versatility
[74232] = "i:52766", -- Enchant Bracer - Precision
[74234] = "i:52767", -- Enchant Cloak - Protection
[74235] = "i:52768", -- Enchant Off-Hand - Superior Intellect
[74236] = "i:52769", -- Enchant Boots - Precision
[74237] = "i:52770", -- Enchant Bracer - Exceptional Versatility
[74238] = "i:52771", -- Enchant Boots - Mastery
[74239] = "i:52772", -- Enchant Bracer - Greater Haste
[74240] = "i:52773", -- Enchant Cloak - Greater Intellect
[74242] = "i:52774", -- Enchant Weapon - Power Torrent
[74244] = "i:52775", -- Enchant Weapon - Windwalk
[74246] = "i:52776", -- Enchant Weapon - Landslide
[74247] = "i:52777", -- Enchant Cloak - Greater Critical Strike
[74248] = "i:52778", -- Enchant Bracer - Greater Critical Strike
[74250] = "i:52779", -- Enchant Chest - Peerless Stats
[74251] = "i:52780", -- Enchant Chest - Greater Stamina
[74252] = "i:52781", -- Enchant Boots - Assassin's Step
[74253] = "i:52782", -- Enchant Boots - Lavawalker
[74254] = "i:52783", -- Enchant Gloves - Mighty Strength
[74255] = "i:52784", -- Enchant Gloves - Greater Mastery
[74256] = "i:52785", -- Enchant Bracer - Greater Speed
[95471] = "i:68134", -- Enchant 2H Weapon - Mighty Agility
[42974] = "i:38948", -- Enchant Weapon - Executioner
[44510] = "i:38963", -- Enchant Weapon - Exceptional Versatility
[44524] = "i:38965", -- Enchant Weapon - Icebreaker
[44576] = "i:38972", -- Enchant Weapon - Lifeward
[44595] = "i:38981", -- Enchant 2H Weapon - Scourgebane
[44621] = "i:38988", -- Enchant Weapon - Giant Slayer
[44629] = "i:38991", -- Enchant Weapon - Exceptional Spellpower
[44630] = "i:38992", -- Enchant 2H Weapon - Greater Savagery
[44633] = "i:38995", -- Enchant Weapon - Exceptional Agility
[46578] = "i:38998", -- Enchant Weapon - Deathfrost
[59625] = "i:43987", -- Enchant Weapon - Black Magic
[60621] = "i:44453", -- Enchant Weapon - Greater Potency
[60691] = "i:44463", -- Enchant 2H Weapon - Massacre
[60707] = "i:44466", -- Enchant Weapon - Superior Potency
[60714] = "i:44467", -- Enchant Weapon - Mighty Spellpower
[59621] = "i:44493", -- Enchant Weapon - Berserking
[59619] = "i:44497", -- Enchant Weapon - Accuracy
[62948] = "i:45056", -- Enchant Staff - Greater Spellpower
[62959] = "i:45060", -- Enchant Staff - Spellpower
[27958] = "i:38912", -- Enchant Chest - Exceptional Mana
[44484] = "i:38951", -- Enchant Gloves - Haste
[44488] = "i:38953", -- Enchant Gloves - Precision
[44489] = "i:38954", -- Enchant Shield - Dodge
[44492] = "i:38955", -- Enchant Chest - Mighty Health
[44500] = "i:38959", -- Enchant Cloak - Superior Agility
[44508] = "i:38961", -- Enchant Boots - Greater Versatility
[44509] = "i:38962", -- Enchant Chest - Greater Versatility
[44513] = "i:38964", -- Enchant Gloves - Greater Assault
[44528] = "i:38966", -- Enchant Boots - Greater Fortitude
[44529] = "i:38967", -- Enchant Gloves - Major Agility
[44555] = "i:38968", -- Enchant Bracer - Exceptional Intellect
[60616] = "i:38971", -- Enchant Bracer - Assault
[44582] = "i:38973", -- Enchant Cloak - Minor Power
[44584] = "i:38974", -- Enchant Boots - Greater Vitality
[44588] = "i:38975", -- Enchant Chest - Exceptional Resilience
[44589] = "i:38976", -- Enchant Boots - Superior Agility
[44591] = "i:38978", -- Enchant Cloak - Superior Dodge
[44592] = "i:38979", -- Enchant Gloves - Exceptional Spellpower
[44593] = "i:38980", -- Enchant Bracer - Major Versatility
[44598] = "i:38984", -- Enchant Bracer - Haste
[60623] = "i:38986", -- Enchant Boots - Icewalker
[44616] = "i:38987", -- Enchant Bracer - Greater Stats
[44623] = "i:38989", -- Enchant Chest - Super Stats
[44625] = "i:38990", -- Enchant Gloves - Armsman
[44631] = "i:38993", -- Enchant Cloak - Shadow Armor
[44635] = "i:38997", -- Enchant Bracer - Greater Spellpower
[47672] = "i:39001", -- Enchant Cloak - Mighty Stamina
[47766] = "i:39002", -- Enchant Chest - Greater Dodge
[47898] = "i:39003", -- Enchant Cloak - Greater Speed
[47899] = "i:39004", -- Enchant Cloak - Wisdom
[47900] = "i:39005", -- Enchant Chest - Super Health
[47901] = "i:39006", -- Enchant Boots - Tuskarr's Vitality
[60606] = "i:44449", -- Enchant Boots - Assault
[60653] = "i:44455", -- Shield Enchant - Greater Intellect
[60609] = "i:44456", -- Enchant Cloak - Speed
[60663] = "i:44457", -- Enchant Cloak - Major Agility
[60668] = "i:44458", -- Enchant Gloves - Crusher
[60692] = "i:44465", -- Enchant Chest - Powerful Stats
[60763] = "i:44469", -- Enchant Boots - Greater Assault
[60767] = "i:44470", -- Enchant Bracer - Superior Spellpower
[44575] = "i:44815", -- Enchant Bracer - Greater Assault
[62256] = "i:44947", -- Enchant Bracer - Major Stamina
[27967] = "i:38917", -- Enchant Weapon - Major Striking
[27968] = "i:38918", -- Enchant Weapon - Major Intellect
[27971] = "i:38919", -- Enchant 2H Weapon - Savagery
[27972] = "i:38920", -- Enchant Weapon - Potency
[27975] = "i:38921", -- Enchant Weapon - Major Spellpower
[27977] = "i:38922", -- Enchant 2H Weapon - Major Agility
[27981] = "i:38923", -- Enchant Weapon - Sunfire
[27982] = "i:38924", -- Enchant Weapon - Soulfrost
[27984] = "i:38925", -- Enchant Weapon - Mongoose
[28003] = "i:38926", -- Enchant Weapon - Spellsurge
[28004] = "i:38927", -- Enchant Weapon - Battlemaster
[34010] = "i:38946", -- Enchant Weapon - Major Healing
[42620] = "i:38947", -- Enchant Weapon - Greater Agility
[27951] = "i:37603", -- Enchant Boots - Dexterity
[25086] = "i:38895", -- Enchant Cloak - Dodge
[27899] = "i:38897", -- Enchant Bracer - Brawn
[27905] = "i:38898", -- Enchant Bracer - Stats
[27906] = "i:38899", -- Enchant Bracer - Greater Dodge
[27911] = "i:38900", -- Enchant Bracer - Superior Healing
[27913] = "i:38901", -- Enchant Bracer - Versatility Prime
[27914] = "i:38902", -- Enchant Bracer - Fortitude
[27917] = "i:38903", -- Enchant Bracer - Spellpower
[27944] = "i:38904", -- Enchant Shield - Lesser Dodge
[27945] = "i:38905", -- Enchant Shield - Intellect
[27946] = "i:38906", -- Enchant Shield - Parry
[27948] = "i:38908", -- Enchant Boots - Vitality
[27950] = "i:38909", -- Enchant Boots - Fortitude
[27954] = "i:38910", -- Enchant Boots - Surefooted
[27957] = "i:38911", -- Enchant Chest - Exceptional Health
[27960] = "i:38913", -- Enchant Chest - Exceptional Stats
[27961] = "i:38914", -- Enchant Cloak - Major Armor
[33990] = "i:38928", -- Enchant Chest - Major Versatility
[33991] = "i:38929", -- Enchant Chest - Versatility Prime
[33992] = "i:38930", -- Enchant Chest - Major Resilience
[33993] = "i:38931", -- Enchant Gloves - Blasting
[33994] = "i:38932", -- Enchant Gloves - Precise Strikes
[33995] = "i:38933", -- Enchant Gloves - Major Strength
[33996] = "i:38934", -- Enchant Gloves - Assault
[33997] = "i:38935", -- Enchant Gloves - Major Spellpower
[33999] = "i:38936", -- Enchant Gloves - Major Healing
[34001] = "i:38937", -- Enchant Bracer - Major Intellect
[34002] = "i:38938", -- Enchant Bracer - Lesser Assault
[34003] = "i:38939", -- Enchant Cloak - PvP Power
[34004] = "i:38940", -- Enchant Cloak - Greater Agility
[34007] = "i:38943", -- Enchant Boots - Cat's Swiftness
[34008] = "i:38944", -- Enchant Boots - Boar's Speed
[34009] = "i:38945", -- Enchant Shield - Major Stamina
[44383] = "i:38949", -- Enchant Shield - Resilience
[46594] = "i:38999", -- Enchant Chest - Dodge
[47051] = "i:39000", -- Enchant Cloak - Greater Dodge
[7745] = "i:38772", -- Enchant 2H Weapon - Minor Impact
[7786] = "i:38779", -- Enchant Weapon - Minor Beastslayer
[7788] = "i:38780", -- Enchant Weapon - Minor Striking
[7793] = "i:38781", -- Enchant 2H Weapon - Lesser Intellect
[13380] = "i:38788", -- Enchant 2H Weapon - Lesser Versatility
[13503] = "i:38794", -- Enchant Weapon - Lesser Striking
[13529] = "i:38796", -- Enchant 2H Weapon - Lesser Impact
[13653] = "i:38813", -- Enchant Weapon - Lesser Beastslayer
[13655] = "i:38814", -- Enchant Weapon - Lesser Elemental Slayer
[13693] = "i:38821", -- Enchant Weapon - Striking
[13695] = "i:38822", -- Enchant 2H Weapon - Impact
[13898] = "i:38838", -- Enchant Weapon - Fiery Weapon
[13915] = "i:38840", -- Enchant Weapon - Demonslaying
[13937] = "i:38845", -- Enchant 2H Weapon - Greater Impact
[13943] = "i:38848", -- Enchant Weapon - Greater Striking
[20029] = "i:38868", -- Enchant Weapon - Icy Chill
[20030] = "i:38869", -- Enchant 2H Weapon - Superior Impact
[20031] = "i:38870", -- Enchant Weapon - Superior Striking
[20032] = "i:38871", -- Enchant Weapon - Lifestealing
[20033] = "i:38872", -- Enchant Weapon - Unholy Weapon
[20034] = "i:38873", -- Enchant Weapon - Crusader
[20035] = "i:38874", -- Enchant 2H Weapon - Major Versatility
[20036] = "i:38875", -- Enchant 2H Weapon - Major Intellect
[21931] = "i:38876", -- Enchant Weapon - Winter's Might
[22749] = "i:38877", -- Enchant Weapon - Spellpower
[22750] = "i:38878", -- Enchant Weapon - Healing Power
[23799] = "i:38879", -- Enchant Weapon - Strength
[23800] = "i:38880", -- Enchant Weapon - Agility
[23803] = "i:38883", -- Enchant Weapon - Mighty Versatility
[23804] = "i:38884", -- Enchant Weapon - Mighty Intellect
[27837] = "i:38896", -- Enchant 2H Weapon - Agility
[64441] = "i:46026", -- Enchant Weapon - Blade Ward
[64579] = "i:46098", -- Enchant Weapon - Blood Draining
[7418] = "i:38679", -- Enchant Bracer - Minor Health
[7420] = "i:38766", -- Enchant Chest - Minor Health
[7426] = "i:38767", -- Enchant Chest - Minor Absorption
[7428] = "i:38768", -- Enchant Bracer - Minor Dodge
[7443] = "i:38769", -- Enchant Chest - Minor Mana
[7457] = "i:38771", -- Enchant Bracer - Minor Stamina
[7748] = "i:38773", -- Enchant Chest - Lesser Health
[7766] = "i:38774", -- Enchant Bracer - Minor Versatility
[7771] = "i:38775", -- Enchant Cloak - Minor Protection
[7776] = "i:38776", -- Enchant Chest - Lesser Mana
[7779] = "i:38777", -- Enchant Bracer - Minor Agility
[7782] = "i:38778", -- Enchant Bracer - Minor Strength
[7857] = "i:38782", -- Enchant Chest - Health
[7859] = "i:38783", -- Enchant Bracer - Lesser Versatility
[7863] = "i:38785", -- Enchant Boots - Minor Stamina
[7867] = "i:38786", -- Enchant Boots - Minor Agility
[13378] = "i:38787", -- Enchant Shield - Minor Stamina
[13419] = "i:38789", -- Enchant Cloak - Minor Agility
[13421] = "i:38790", -- Enchant Cloak - Lesser Protection
[13464] = "i:38791", -- Enchant Shield - Lesser Protection
[13485] = "i:38792", -- Enchant Shield - Lesser Versatility
[13501] = "i:38793", -- Enchant Bracer - Lesser Stamina
[13536] = "i:38797", -- Enchant Bracer - Lesser Strength
[13538] = "i:38798", -- Enchant Chest - Lesser Absorption
[13607] = "i:38799", -- Enchant Chest - Mana
[13612] = "i:38800", -- Enchant Gloves - Mining
[13617] = "i:38801", -- Enchant Gloves - Herbalism
[13620] = "i:38802", -- Enchant Gloves - Fishing
[13622] = "i:38803", -- Enchant Bracer - Lesser Intellect
[13626] = "i:38804", -- Enchant Chest - Minor Stats
[13631] = "i:38805", -- Enchant Shield - Lesser Stamina
[13635] = "i:38806", -- Enchant Cloak - Defense
[13637] = "i:38807", -- Enchant Boots - Lesser Agility
[13640] = "i:38808", -- Enchant Chest - Greater Health
[13642] = "i:38809", -- Enchant Bracer - Versatility
[13644] = "i:38810", -- Enchant Boots - Lesser Stamina
[13646] = "i:38811", -- Enchant Bracer - Lesser Dodge
[13648] = "i:38812", -- Enchant Bracer - Stamina
[13659] = "i:38816", -- Enchant Shield - Versatility
[13661] = "i:38817", -- Enchant Bracer - Strength
[13663] = "i:38818", -- Enchant Chest - Greater Mana
[13687] = "i:38819", -- Enchant Boots - Lesser Versatility
[13689] = "i:38820", -- Enchant Shield - Lesser Parry
[13698] = "i:38823", -- Enchant Gloves - Skinning
[13700] = "i:38824", -- Enchant Chest - Lesser Stats
[13746] = "i:38825", -- Enchant Cloak - Greater Defense
[13815] = "i:38827", -- Enchant Gloves - Agility
[13817] = "i:38828", -- Enchant Shield - Stamina
[13822] = "i:38829", -- Enchant Bracer - Intellect
[13836] = "i:38830", -- Enchant Boots - Stamina
[13841] = "i:38831", -- Enchant Gloves - Advanced Mining
[13846] = "i:38832", -- Enchant Bracer - Greater Versatility
[13858] = "i:38833", -- Enchant Chest - Superior Health
[13868] = "i:38834", -- Enchant Gloves - Advanced Herbalism
[13882] = "i:38835", -- Enchant Cloak - Lesser Agility
[13887] = "i:38836", -- Enchant Gloves - Strength
[13890] = "i:38837", -- Enchant Boots - Minor Speed
[13905] = "i:38839", -- Enchant Shield - Greater Versatility
[13917] = "i:38841", -- Enchant Chest - Superior Mana
[13931] = "i:38842", -- Enchant Bracer - Dodge
[13935] = "i:38844", -- Enchant Boots - Agility
[13939] = "i:38846", -- Enchant Bracer - Greater Strength
[13941] = "i:38847", -- Enchant Chest - Stats
[13945] = "i:38849", -- Enchant Bracer - Greater Stamina
[13947] = "i:38850", -- Enchant Gloves - Riding Skill
[13948] = "i:38851", -- Enchant Gloves - Minor Haste
[20008] = "i:38852", -- Enchant Bracer - Greater Intellect
[20009] = "i:38853", -- Enchant Bracer - Superior Versatility
[20010] = "i:38854", -- Enchant Bracer - Superior Strength
[20011] = "i:38855", -- Enchant Bracer - Superior Stamina
[20012] = "i:38856", -- Enchant Gloves - Greater Agility
[20013] = "i:38857", -- Enchant Gloves - Greater Strength
[20015] = "i:38859", -- Enchant Cloak - Superior Defense
[20016] = "i:38860", -- Enchant Shield - Vitality
[20017] = "i:38861", -- Enchant Shield - Greater Stamina
[20020] = "i:38862", -- Enchant Boots - Greater Stamina
[20023] = "i:38863", -- Enchant Boots - Greater Agility
[20024] = "i:38864", -- Enchant Boots - Versatility
[20025] = "i:38865", -- Enchant Chest - Greater Stats
[20026] = "i:38866", -- Enchant Chest - Major Health
[20028] = "i:38867", -- Enchant Chest - Major Mana
[23801] = "i:38881", -- Enchant Bracer - Argent Versatility
[23802] = "i:38882", -- Enchant Bracer - Healing Power
[25072] = "i:38885", -- Enchant Gloves - Threat
[25073] = "i:38886", -- Enchant Gloves - Shadow Power
[25074] = "i:38887", -- Enchant Gloves - Frost Power
[25078] = "i:38888", -- Enchant Gloves - Fire Power
[25079] = "i:38889", -- Enchant Gloves - Healing Power
[25080] = "i:38890", -- Enchant Gloves - Superior Agility
[25083] = "i:38893", -- Enchant Cloak - Stealth
[25084] = "i:38894", -- Enchant Cloak - Subtlety
[44506] = "i:38960", -- Enchant Gloves - Gatherer
[63746] = "i:45628", -- Enchant Boots - Lesser Accuracy
[71692] = "i:50816", -- Enchant Gloves - Angler
[190954] = "i:128554", -- Enchant Shoulder - Boon of the Scavenger
[190988] = "i:128558", -- Enchant Gloves - Legion Herbalism
[190989] = "i:128559", -- Enchant Gloves - Legion Mining
[190990] = "i:128560", -- Enchant Gloves - Legion Skinning
[190991] = "i:128561", -- Enchant Gloves - Legion Surveying
[190869] = "i:128540", -- Enchant Ring - Word of Versatility Rank 1
[190995] = "i:128540", -- Enchant Ring - Word of Versatility Rank 2
[191012] = "i:128540", -- Enchant Ring - Word of Versatility Rank 3
[190866] = "i:128537", -- Enchant Ring - Word of Critical Strike Rank 1
[190992] = "i:128537", -- Enchant Ring - Word of Critical Strike Rank 2
[191009] = "i:128537", -- Enchant Ring - Word of Critical Strike Rank 3
[190875] = "i:128546", -- Enchant Cloak - Word of Agility Rank 1
[191001] = "i:128546", -- Enchant Cloak - Word of Agility Rank 2
[191018] = "i:128546", -- Enchant Cloak - Word of Agility Rank 3
[190867] = "i:128538", -- Enchant Ring - Word of Haste Rank 1
[190993] = "i:128538", -- Enchant Ring - Word of Haste Rank 2
[191010] = "i:128538", -- Enchant Ring - Word of Haste Rank 3
[190876] = "i:128547", -- Enchant Cloak - Word of Intellect Rank 1
[191002] = "i:128547", -- Enchant Cloak - Word of Intellect Rank 2
[191019] = "i:128547", -- Enchant Cloak - Word of Intellect Rank 3
[190868] = "i:128539", -- Enchant Ring - Word of Mastery Rank 1
[190994] = "i:128539", -- Enchant Ring - Word of Mastery Rank 2
[191011] = "i:128539", -- Enchant Ring - Word of Mastery Rank 3
[190874] = "i:128545", -- Enchant Cloak - Word of Strength Rank 1
[191000] = "i:128545", -- Enchant Cloak - Word of Strength Rank 2
[191017] = "i:128545", -- Enchant Cloak - Word of Strength Rank 3
[228408] = "i:141910", -- Enchant Neck - Mark Of The Ancient Priestess Rank 1
[228409] = "i:141910", -- Enchant Neck - Mark Of The Ancient Priestess Rank 2
[228410] = "i:141910", -- Enchant Neck - Mark Of The Ancient Priestess Rank 3
[190892] = "i:128551", -- Enchant Neck - Mark Of The Claw Rank 1
[191006] = "i:128551", -- Enchant Neck - Mark Of The Claw Rank 2
[191023] = "i:128551", -- Enchant Neck - Mark Of The Claw Rank 3
[190893] = "i:128552", -- Enchant Neck - Mark Of The Distant Army Rank 1
[191007] = "i:128552", -- Enchant Neck - Mark Of The Distant Army Rank 2
[191024] = "i:128552", -- Enchant Neck - Mark Of The Distant Army Rank 3
[228402] = "i:141908", -- Enchant Neck - Mark Of The Heavy Hide Rank 1
[228403] = "i:141908", -- Enchant Neck - Mark Of The Heavy Hide Rank 2
[228404] = "i:141908", -- Enchant Neck - Mark Of The Heavy Hide Rank 3
[190894] = "i:128553", -- Enchant Neck - Mark of the Hidden Satyr Rank 1
[191008] = "i:128553", -- Enchant Neck - Mark of the Hidden Satyr Rank 2
[191025] = "i:128553", -- Enchant Neck - Mark of the Hidden Satyr Rank 3
[228405] = "i:141909", -- Enchant Neck - Mark of the Trained Soldier Rank 1
[228406] = "i:141909", -- Enchant Neck - Mark of the Trained Soldier Rank 2
[228407] = "i:141909", -- Enchant Neck - Mark of the Trained Soldier Rank 3
[190870] = "i:128541", -- Enchant Ring - Binding Of Critical Strike Rank 1
[190996] = "i:128541", -- Enchant Ring - Binding Of Critical Strike Rank 2
[191013] = "i:128541", -- Enchant Ring - Binding Of Critical Strike Rank 3
[190871] = "i:128542", -- Enchant Ring - Binding Of Haste Rank 1
[190997] = "i:128542", -- Enchant Ring - Binding Of Haste Rank 2
[191014] = "i:128542", -- Enchant Ring - Binding Of Haste Rank 3
[190872] = "i:128543", -- Enchant Ring - Binding Of Mastery Rank 1
[190998] = "i:128543", -- Enchant Ring - Binding Of Mastery Rank 2
[191015] = "i:128543", -- Enchant Ring - Binding Of Mastery Rank 3
[190873] = "i:128544", -- Enchant Ring - Binding Of Versatility Rank 1
[190999] = "i:128544", -- Enchant Ring - Binding Of Versatility Rank 2
[191016] = "i:128544", -- Enchant Ring - Binding Of Versatility Rank 3
[190877] = "i:128548", -- Enchant Cloak - Binding Of Strength Rank 1
[191003] = "i:128548", -- Enchant Cloak - Binding Of Strength Rank 2
[191020] = "i:128548", -- Enchant Cloak - Binding Of Strength Rank 3
[190878] = "i:128549", -- Enchant Cloak - Binding Of Agility Rank 1
[191004] = "i:128549", -- Enchant Cloak - Binding Of Agility Rank 2
[191021] = "i:128549", -- Enchant Cloak - Binding Of Agility Rank 3
[190879] = "i:128550", -- Enchant Cloak - Binding Of Intellect Rank 1
[191005] = "i:128550", -- Enchant Cloak - Binding Of Intellect Rank 2
[191022] = "i:128550", -- Enchant Cloak - Binding Of Intellect Rank 3
[235698] = "i:144307", -- Enchant Neck - Mark of the Deadly Rank 1
[235702] = "i:144307", -- Enchant Neck - Mark of the Deadly Rank 2
[235706] = "i:144307", -- Enchant Neck - Mark of the Deadly Rank 3
[235695] = "i:144304", -- Enchant Neck - Mark of the Master Rank 1
[235699] = "i:144304", -- Enchant Neck - Mark of the Master Rank 2
[235703] = "i:144304", -- Enchant Neck - Mark of the Master Rank 3
[235697] = "i:144306", -- Enchant Neck - Mark of the Quick Rank 1
[235701] = "i:144306", -- Enchant Neck - Mark of the Quick Rank 2
[235705] = "i:144306", -- Enchant Neck - Mark of the Quick Rank 3
[235696] = "i:144305", -- Enchant Neck - Mark of the Versatile Rank 1
[235704] = "i:144305", -- Enchant Neck - Mark of the Versatile Rank 2
[235700] = "i:144305", -- Enchant Neck - Mark of the Versatile Rank 3
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function ProfessionInfo.GetName(key)
local name = PROFESSION_NAMES[key]
assert(name)
return name
end
function ProfessionInfo.IsSubNameClassic(str)
assert(TSM.IsWowClassic())
return CLASSIC_SUB_NAMES[str] or false
end
function ProfessionInfo.GetVellumItemString()
return VELLUM_ITEM_STRING
end
function ProfessionInfo.IsEngineeringTinker(spellId)
return ENGINEERING_TINKERS[spellId] or false
end
function ProfessionInfo.IsMassMill(spellId)
return MASS_MILLING_RECIPES[spellId] and true or false
end
function ProfessionInfo.GetIndirectCraftResult(spellId)
return ENCHANTING_RECIPIES[spellId] or MASS_MILLING_RECIPES[spellId] or nil
end

512
LibTSM/Data/Prospect.lua Normal file
View File

@@ -0,0 +1,512 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Prospect = TSM.Init("Data.Prospect")
local private = {}
-- ============================================================================
-- Prospect Data
-- ============================================================================
local DATA = TSM.IsWowClassic() and {} or {
-- ======================================== Uncommon Gems ======================================
["i:774"] = { -- Malachite
["i:2770"] = {matRate = 0.5000, minAmount = 1, maxAmount = 1, amountOfMats = 0.1000}, -- Copper Ore
},
["i:818"] = { -- Tigerseye
["i:2770"] = {matRate = 0.5000, minAmount = 1, maxAmount = 1, amountOfMats = 0.1000}, -- Copper Ore
},
["i:1210"] = { -- Shadowgem
["i:2771"] = {matRate = 0.3800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0800}, -- Tin Ore
["i:2770"] = {matRate = 0.1000, minAmount = 1, maxAmount = 1, amountOfMats = 0.0200}, -- Copper Ore
},
["i:1206"] = { -- Moss Agate
["i:2771"] = {matRate = 0.3800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0800}, -- Tin Ore
},
["i:1705"] = { -- Lesser Moonstone
["i:2771"] = {matRate = 0.3800, minAmount = 1, maxAmount = 2, amountOfMats = 0.080}, -- Tin Ore
["i:2772"] = {matRate = 0.3500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0725}, -- Iron Ore
},
["i:1529"] = { -- Jade
["i:2772"] = {matRate = 0.3500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0725}, -- Iron Ore
["i:2771"] = {matRate = 0.0375, minAmount = 1, maxAmount = 1, amountOfMats = 0.0075}, -- Tin Ore
},
["i:3864"] = { -- Citrine
["i:2772"] = {matRate = 0.3500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0725}, -- Iron Ore
["i:3858"] = {matRate = 0.3500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0725}, -- Mithril Ore
["i:2771"] = {matRate = 0.0375, minAmount = 1, maxAmount = 1, amountOfMats = 0.0075}, -- Tin Ore
},
["i:7909"] = { -- Aquamarine
["i:3858"] = {matRate = 0.3500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0725}, -- Mithril Ore
["i:2772"] = {matRate = 0.0500, minAmount = 1, maxAmount = 1, amountOfMats = 0.0100}, -- Iron Ore
["i:2771"] = {matRate = 0.0375, minAmount = 1, maxAmount = 1, amountOfMats = 0.0075}, -- Tin Ore
},
["i:7910"] = { -- Star Ruby
[ "i:3858"] = {matRate = 0.3500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0725}, -- Mithril Ore
["i:10620"] = {matRate = 0.1550, minAmount = 1, maxAmount = 2, amountOfMats = 0.0320}, -- Thorium Ore
[ "i:2772"] = {matRate = 0.0500, minAmount = 1, maxAmount = 1, amountOfMats = 0.0100}, -- Iron Ore
},
["i:12361"] = { -- Blue Sapphire
["i:10620"] = {matRate = 0.3100, minAmount = 1, maxAmount = 2, amountOfMats = 0.0660}, -- Thorium Ore
[ "i:3858"] = {matRate = 0.0250, minAmount = 1, maxAmount = 1, amountOfMats = 0.0050}, -- Mithril Ore
},
["i:12799"] = { -- Large Opal
["i:10620"] = {matRate = 0.3100, minAmount = 1, maxAmount = 2, amountOfMats = 0.0660}, -- Thorium Ore
[ "i:3858"] = {matRate = 0.0250, minAmount = 1, maxAmount = 1, amountOfMats = 0.0050}, -- Mithril Ore
},
["i:12800"] = { -- Azerothian Diamond
["i:10620"] = {matRate = 0.3100, minAmount = 1, maxAmount = 2, amountOfMats = 0.0660}, -- Thorium Ore
[ "i:3858"] = {matRate = 0.0250, minAmount = 1, maxAmount = 1, amountOfMats = 0.0050}, -- Mithril Ore
},
["i:12364"] = { -- Huge Emerald
["i:10620"] = {matRate = 0.3100, minAmount = 1, maxAmount = 2, amountOfMats = 0.0660}, -- Thorium Ore
[ "i:3858"] = {matRate = 0.0250, minAmount = 1, maxAmount = 1, amountOfMats = 0.0050}, -- Mithril Ore
},
["i:23117"] = { -- Azure Moonstone
["i:23424"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.5000, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Adamantite Ore
},
["i:23077"] = { -- Blood Garnet
["i:23424"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Adamantite Ore
},
["i:23079"] = { -- Deep Peridot
["i:23424"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Adamantite Ore
},
["i:21929"] = { -- Flame Spessarite
["i:23424"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Adamantite Ore
},
["i:23112"] = { -- Golden Draenite
["i:23424"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Adamantite Ore
},
["i:23107"] = { -- Shadow Draenite
["i:23424"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Adamantite Ore
},
["i:36917"] = { -- Bloodstone
["i:36909"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Cobalt Ore
["i:36912"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Saronite Ore
["i:36910"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0525}, -- Titanium Ore
},
["i:36923"] = { -- Chalcedony
["i:36909"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Cobalt Ore
["i:36912"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Saronite Ore
["i:36910"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0525}, -- Titanium Ore
},
["i:36932"] = { -- Dark Jade
["i:36909"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Cobalt Ore
["i:36912"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Saronite Ore
["i:36910"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0525}, -- Titanium Ore
},
["i:36929"] = { -- Huge Citrine
["i:36909"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Cobalt Ore
["i:36912"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Saronite Ore
["i:36910"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0525}, -- Titanium Ore
},
["i:36926"] = { -- Shadow Crystal
["i:36909"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Cobalt Ore
["i:36912"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Saronite Ore
["i:36910"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0525}, -- Titanium Ore
},
["i:36920"] = { -- Sun Crystal
["i:36909"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Cobalt Ore
["i:36912"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Saronite Ore
["i:36910"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0525}, -- Titanium Ore
},
["i:52182"] = { -- Jasper
["i:53038"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Obsidium Ore
["i:52185"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Elementium Ore
["i:52183"] = {matRate = 0.1650, minAmount = 1, maxAmount = 1, amountOfMats = 0.0330}, -- Pyrite Ore
},
["i:52180"] = { -- Nightstone
["i:53038"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Obsidium Ore
["i:52185"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Elementium Ore
["i:52183"] = {matRate = 0.1650, minAmount = 1, maxAmount = 1, amountOfMats = 0.0330}, -- Pyrite Ore
},
["i:52178"] = { -- Zephyrite
["i:53038"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Obsidium Ore
["i:52185"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Elementium Ore
["i:52183"] = {matRate = 0.1650, minAmount = 1, maxAmount = 1, amountOfMats = 0.0330}, -- Pyrite Ore
},
["i:52179"] = { -- Alicite
["i:53038"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Obsidium Ore
["i:52185"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Elementium Ore
["i:52183"] = {matRate = 0.1650, minAmount = 1, maxAmount = 1, amountOfMats = 0.0330}, -- Pyrite Ore
},
["i:52177"] = { -- Carnelian
["i:53038"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Obsidium Ore
["i:52185"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Elementium Ore
["i:52183"] = {matRate = 0.1650, minAmount = 1, maxAmount = 1, amountOfMats = 0.0330}, -- Pyrite Ore
},
["i:52181"] = { -- Hessonite
["i:53038"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0528}, -- Obsidium Ore
["i:52185"] = {matRate = 0.1800, minAmount = 1, maxAmount = 2, amountOfMats = 0.0365}, -- Elementium Ore
["i:52183"] = {matRate = 0.1650, minAmount = 1, maxAmount = 1, amountOfMats = 0.0330}, -- Pyrite Ore
},
["i:76130"] = { -- Tiger Opal
["i:72092"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0485}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0505}, -- Kyparite
["i:72103"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:76133"] = { -- Lapis Lazuli
["i:72092"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0485}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0505}, -- Kyparite
["i:72103"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:76134"] = { -- Sunstone
["i:72092"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0485}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0505}, -- Kyparite
["i:72103"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:76135"] = { -- Roguestone
["i:72092"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0485}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0505}, -- Kyparite
["i:72103"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:76136"] = { -- Pandarian Garnet
["i:72092"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0485}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0505}, -- Kyparite
["i:72103"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:76137"] = { -- Alexandrite
["i:72092"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0485}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.2350, minAmount = 1, maxAmount = 2, amountOfMats = 0.0505}, -- Kyparite
["i:72103"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1700, minAmount = 1, maxAmount = 1, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:130173"] = { -- Deep Amber
["i:123918"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0110}, -- Leystone Ore
["i:123919"] = {matRate = 0.0600, minAmount = 2, maxAmount = 5, amountOfMats = 0.0420}, -- Felslate
},
["i:130174"] = { -- Azsunite
["i:123918"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0110}, -- Leystone Ore
["i:123919"] = {matRate = 0.0600, minAmount = 2, maxAmount = 5, amountOfMats = 0.0420}, -- Felslate
},
["i:130176"] = { -- Skystone
["i:123918"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0110}, -- Leystone Ore
["i:123919"] = {matRate = 0.0600, minAmount = 2, maxAmount = 5, amountOfMats = 0.0420}, -- Felslate
},
["i:130177"] = { -- Queen's Opal
["i:123918"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0110}, -- Leystone Ore
["i:123919"] = {matRate = 0.0600, minAmount = 2, maxAmount = 5, amountOfMats = 0.0420}, -- Felslate
},
["i:130175"] = { -- Chaotic Spinel
["i:123918"] = {matRate = 0.3500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0080}, -- Leystone Ore
["i:123919"] = {matRate = 0.2500, minAmount = 2, maxAmount = 5, amountOfMats = 0.0175}, -- Felslate
},
["i:130172"] = { -- Sangrite
["i:123918"] = {matRate = 0.2500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0060}, -- Leystone Ore
["i:123919"] = {matRate = 0.3500, minAmount = 2, maxAmount = 5, amountOfMats = 0.0245}, -- Felslate
},
["i:153700"] = { -- Golden Beryl
["i:152512"] = {matRate = 0.1800, minAmount = 1, maxAmount = 4, amountOfMats = 0.0552}, -- Monelite Ore
["i:152579"] = {matRate = 0.1950, minAmount = 1, maxAmount = 4, amountOfMats = 0.0603}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.2100, minAmount = 1, maxAmount = 4, amountOfMats = 0.0660}, -- Platinum Ore
},
["i:153701"] = { -- Rubellite
["i:152512"] = {matRate = 0.1800, minAmount = 1, maxAmount = 4, amountOfMats = 0.0552}, -- Monelite Ore
["i:152579"] = {matRate = 0.1950, minAmount = 1, maxAmount = 4, amountOfMats = 0.0603}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.2100, minAmount = 1, maxAmount = 4, amountOfMats = 0.0660}, -- Platinum Ore
},
["i:153702"] = { -- Kubiline
["i:152512"] = {matRate = 0.1800, minAmount = 1, maxAmount = 4, amountOfMats = 0.0552}, -- Monelite Ore
["i:152579"] = {matRate = 0.1950, minAmount = 1, maxAmount = 4, amountOfMats = 0.0603}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.2100, minAmount = 1, maxAmount = 4, amountOfMats = 0.0660}, -- Platinum Ore
},
["i:153703"] = { -- Solstone
["i:152512"] = {matRate = 0.1800, minAmount = 1, maxAmount = 4, amountOfMats = 0.0552}, -- Monelite Ore
["i:152579"] = {matRate = 0.1950, minAmount = 1, maxAmount = 4, amountOfMats = 0.0603}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.2100, minAmount = 1, maxAmount = 4, amountOfMats = 0.0660}, -- Platinum Ore
},
["i:153704"] = { -- Viridium
["i:152512"] = {matRate = 0.1800, minAmount = 1, maxAmount = 4, amountOfMats = 0.0552}, -- Monelite Ore
["i:152579"] = {matRate = 0.1950, minAmount = 1, maxAmount = 4, amountOfMats = 0.0603}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.2100, minAmount = 1, maxAmount = 4, amountOfMats = 0.0660}, -- Platinum Ore
},
["i:153705"] = { -- Kyanite
["i:152512"] = {matRate = 0.1800, minAmount = 1, maxAmount = 4, amountOfMats = 0.0552}, -- Monelite Ore
["i:152579"] = {matRate = 0.1950, minAmount = 1, maxAmount = 4, amountOfMats = 0.0603}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.2100, minAmount = 1, maxAmount = 4, amountOfMats = 0.0660}, -- Platinum Ore
},
-- ========================================== Rare Gems ========================================
["i:23440"] = { -- Dawnstone
["i:23424"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.0400, minAmount = 1, maxAmount = 1, amountOfMats = 0.0080}, -- Adamantite Ore
},
["i:23436"] = { -- Living Ruby
["i:23424"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.0400, minAmount = 1, maxAmount = 1, amountOfMats = 0.0080}, -- Adamantite Ore
},
["i:23441"] = { -- Nightseye
["i:23424"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.0400, minAmount = 1, maxAmount = 1, amountOfMats = 0.0080}, -- Adamantite Ore
},
["i:23439"] = { -- Noble Topaz
["i:23424"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.0400, minAmount = 1, maxAmount = 1, amountOfMats = 0.0080}, -- Adamantite Ore
},
["i:23438"] = { -- Star of Elune
["i:23424"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.0400, minAmount = 1, maxAmount = 1, amountOfMats = 0.0080}, -- Adamantite Ore
},
["i:23437"] = { -- Talasite
["i:23424"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Fel Iron Ore
["i:23425"] = {matRate = 0.0400, minAmount = 1, maxAmount = 1, amountOfMats = 0.0080}, -- Adamantite Ore
},
["i:36921"] = { -- Autumn's Glow
["i:36909"] = {matRate = 0.0125, minAmount = 1, maxAmount = 2, amountOfMats = 0.0025}, -- Cobalt Ore
["i:36912"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Saronite Ore
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:36933"] = { -- Forest Emerald
["i:36909"] = {matRate = 0.0125, minAmount = 1, maxAmount = 2, amountOfMats = 0.0025}, -- Cobalt Ore
["i:36912"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Saronite Ore
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:36930"] = { -- Monarch Topaz
["i:36909"] = {matRate = 0.0125, minAmount = 1, maxAmount = 2, amountOfMats = 0.0025}, -- Cobalt Ore
["i:36912"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Saronite Ore
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:36918"] = { -- Scarlet Ruby
["i:36909"] = {matRate = 0.0125, minAmount = 1, maxAmount = 2, amountOfMats = 0.0025}, -- Cobalt Ore
["i:36912"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Saronite Ore
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:36924"] = { -- Sky Sapphire
["i:36909"] = {matRate = 0.0125, minAmount = 1, maxAmount = 2, amountOfMats = 0.0025}, -- Cobalt Ore
["i:36912"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Saronite Ore
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:36927"] = { -- Twilight Opal
["i:36909"] = {matRate = 0.0125, minAmount = 1, maxAmount = 2, amountOfMats = 0.0025}, -- Cobalt Ore
["i:36912"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Saronite Ore
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:52192"] = { -- Dream Emerald
["i:53038"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Obsidium Ore
["i:52185"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0091}, -- Elementium Ore
["i:52183"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Pyrite Ore
},
["i:52193"] = { -- Ember Topaz
["i:53038"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Obsidium Ore
["i:52185"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0091}, -- Elementium Ore
["i:52183"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Pyrite Ore
},
["i:52190"] = { -- Inferno Ruby
["i:53038"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Obsidium Ore
["i:52185"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0091}, -- Elementium Ore
["i:52183"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Pyrite Ore
},
["i:52195"] = { -- Amberjewel
["i:53038"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Obsidium Ore
["i:52185"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0091}, -- Elementium Ore
["i:52183"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Pyrite Ore
},
["i:52194"] = { -- Demonseye
["i:53038"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Obsidium Ore
["i:52185"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0091}, -- Elementium Ore
["i:52183"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Pyrite Ore
},
["i:52191"] = { -- Ocean Sapphire
["i:53038"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Obsidium Ore
["i:52185"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0091}, -- Elementium Ore
["i:52183"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Pyrite Ore
},
["i:76131"] = { -- Primordial Ruby
["i:72092"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Kyparite
["i:72103"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:76138"] = { -- River's Heart
["i:72092"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Kyparite
["i:72103"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:76139"] = { -- Wild Jade
["i:72092"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Kyparite
["i:72103"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:76140"] = { -- Vermillion Onyx
["i:72092"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Kyparite
["i:72103"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:76141"] = { -- Imperial Amethyst
["i:72092"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Kyparite
["i:72103"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:76142"] = { -- Sun's Radiance
["i:72092"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Ghost Iron Ore
["i:72093"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0090}, -- Kyparite
["i:72103"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- White Trillium Ore
["i:72094"] = {matRate = 0.1650, minAmount = 1, maxAmount = 2, amountOfMats = 0.0340}, -- Black Trillium Ore
},
["i:130179"] = { -- Eye of Prophecy
["i:123918"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Leystone Ore
["i:123919"] = {matRate = 0.0125, minAmount = 2, maxAmount = 5, amountOfMats = 0.0083}, -- Felslate
},
["i:130180"] = { -- Dawnlight
["i:123918"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Leystone Ore
["i:123919"] = {matRate = 0.0125, minAmount = 2, maxAmount = 5, amountOfMats = 0.0083}, -- Felslate
},
["i:130182"] = { -- Maelstrom Sapphire
["i:123918"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Leystone Ore
["i:123919"] = {matRate = 0.0125, minAmount = 2, maxAmount = 5, amountOfMats = 0.0083}, -- Felslate
},
["i:130183"] = { -- Shadowruby
["i:123918"] = {matRate = 0.0125, minAmount = 1, maxAmount = 1, amountOfMats = 0.0025}, -- Leystone Ore
["i:123919"] = {matRate = 0.0125, minAmount = 2, maxAmount = 5, amountOfMats = 0.0083}, -- Felslate
},
["i:130178"] = { -- FuryStone
["i:123918"] = {matRate = 0.0050, minAmount = 1, maxAmount = 1, amountOfMats = 0.0010}, -- Leystone Ore
["i:123919"] = {matRate = 0.0075, minAmount = 2, maxAmount = 5, amountOfMats = 0.0048}, -- Felslate
},
["i:130181"] = { -- Pandemonite
["i:123918"] = {matRate = 0.0075, minAmount = 1, maxAmount = 1, amountOfMats = 0.0015}, -- Leystone Ore
["i:123919"] = {matRate = 0.0050, minAmount = 2, maxAmount = 5, amountOfMats = 0.0033}, -- Felslate
},
["i:154120"] = { -- Owlseye
["i:152512"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0092}, -- Monelite Ore
["i:152579"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.1150, minAmount = 1, maxAmount = 2, amountOfMats = 0.0237}, -- Platinum Ore
},
["i:154121"] = { -- Scarlet Diamond
["i:152512"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0092}, -- Monelite Ore
["i:152579"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.1150, minAmount = 1, maxAmount = 2, amountOfMats = 0.0237}, -- Platinum Ore
},
["i:154122"] = { -- Tidal Amethyst
["i:152512"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0092}, -- Monelite Ore
["i:152579"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.1150, minAmount = 1, maxAmount = 2, amountOfMats = 0.0237}, -- Platinum Ore
},
["i:154123"] = { -- Amberblaze
["i:152512"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0092}, -- Monelite Ore
["i:152579"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.1150, minAmount = 1, maxAmount = 2, amountOfMats = 0.0237}, -- Platinum Ore
},
["i:154124"] = { -- Laribole
["i:152512"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0092}, -- Monelite Ore
["i:152579"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.1150, minAmount = 1, maxAmount = 2, amountOfMats = 0.0237}, -- Platinum Ore
},
["i:154125"] = { -- Royal Quartz
["i:152512"] = {matRate = 0.0450, minAmount = 1, maxAmount = 2, amountOfMats = 0.0092}, -- Monelite Ore
["i:152579"] = {matRate = 0.0750, minAmount = 1, maxAmount = 2, amountOfMats = 0.0152}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.1150, minAmount = 1, maxAmount = 2, amountOfMats = 0.0237}, -- Platinum Ore
},
-- ========================================== Epic Gems ========================================
["i:36931"] = { -- Ametrine
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:36919"] = { -- Cardinal Ruby
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:36928"] = { -- Dreadstone
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:36934"] = { -- Eye of Zul
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:36922"] = { -- King's Amber
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:36925"] = { -- Majestic Zircon
["i:36910"] = {matRate = 0.0500, minAmount = 1, maxAmount = 2, amountOfMats = 0.0100}, -- Titanium Ore
},
["i:151719"] = { -- Lightsphene
["i:151564"] = {matRate = 0.0300, minAmount = 1, maxAmount = 1, amountOfMats = 0.0060}, -- Empyrium
},
["i:151720"] = { -- Chemirine
["i:151564"] = {matRate = 0.0300, minAmount = 1, maxAmount = 1, amountOfMats = 0.0060}, -- Empyrium
},
["i:151722"] = { -- Florid Malachite
["i:151564"] = {matRate = 0.0300, minAmount = 1, maxAmount = 1, amountOfMats = 0.0060}, -- Empyrium
},
["i:151721"] = { -- Hesselian
["i:151564"] = {matRate = 0.0300, minAmount = 1, maxAmount = 1, amountOfMats = 0.0060}, -- Empyrium
},
["i:151718"] = { -- Argulite
["i:151564"] = {matRate = 0.0250, minAmount = 1, maxAmount = 1, amountOfMats = 0.0050}, -- Empyrium
},
["i:151579"] = { -- Labradorite
["i:151564"] = {matRate = 0.0225, minAmount = 1, maxAmount = 1, amountOfMats = 0.0045}, -- Empyrium
},
["i:153706"] = { -- Kraken's Eye
["i:152512"] = {matRate = 0.0400, minAmount = 1, maxAmount = 1, amountOfMats = 0.0080}, -- Monelite Ore
["i:152579"] = {matRate = 0.0375, minAmount = 1, maxAmount = 1, amountOfMats = 0.0075}, -- Storm Silver Ore
["i:152513"] = {matRate = 0.0450, minAmount = 1, maxAmount = 1, amountOfMats = 0.0090}, -- Platinum Ore
},
["i:168188"] = { -- Sage Agate
["i:168185"] = {matRate = 0.1500, minAmount = 1, maxAmount = 1, amountOfMats = 0.0300}, -- Osmenite Ore
},
["i:168193"] = { -- Azsharine
["i:168185"] = {matRate = 0.1500, minAmount = 1, maxAmount = 1, amountOfMats = 0.0300}, -- Osmenite Ore
},
["i:168189"] = { -- Dark Opal
["i:168185"] = {matRate = 0.1500, minAmount = 1, maxAmount = 1, amountOfMats = 0.0300}, -- Osmenite Ore
},
["i:168190"] = { -- Lava Lazuli
["i:168185"] = {matRate = 0.1500, minAmount = 1, maxAmount = 1, amountOfMats = 0.0300}, -- Osmenite Ore
},
["i:168191"] = { -- Sea Currant
["i:168185"] = {matRate = 0.1500, minAmount = 1, maxAmount = 1, amountOfMats = 0.0300}, -- Osmenite Ore
},
["i:168192"] = { -- Sand Spinel
["i:168185"] = {matRate = 0.1500, minAmount = 1, maxAmount = 1, amountOfMats = 0.0300}, -- Osmenite Ore
},
["i:168635"] = { -- Leviathan's Eye
["i:168185"] = {matRate = 0.1000, minAmount = 1, maxAmount = 1, amountOfMats = 0.0200}, -- Osmenite Ore
},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function Prospect.TargetItemIterator()
return private.TableKeyIterator, DATA, nil
end
function Prospect.SourceItemIterator(targetItemString)
return private.TableKeyIterator, DATA[targetItemString], nil
end
function Prospect.GetRate(targetItemString, sourceItemString)
return DATA[targetItemString][sourceItemString].amountOfMats, DATA[targetItemString][sourceItemString].matRate, DATA[targetItemString][sourceItemString].minAmount, DATA[targetItemString][sourceItemString].maxAmount
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.TableKeyIterator(tbl, index)
index = next(tbl, index)
return index
end

331
LibTSM/Data/Transform.lua Normal file
View File

@@ -0,0 +1,331 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Transform = TSM.Init("Data.Transform")
local private = {}
-- ============================================================================
-- Transform Data
-- ============================================================================
local DATA = {
-- ================================== Essences / Illusion Dust =================================
["i:52719"] = {
["i:52718"] = 1/3, -- Greater Celestial Essence
},
["i:52718"] = {
["i:52719"] = 3, -- Lesser Celestial Essence
},
["i:34055"] = {
["i:34056"] = 1/3, -- Greater Cosmic Essence
},
["i:34056"] = {
["i:34055"] = 3, -- Lesser Cosmic Essence
},
["i:22446"] = {
["i:22447"] = 1/3, -- Greater Planar Essence
},
["i:22447"] = {
["i:22446"] = 3, -- Lesser Planar Essence
},
["i:16203"] = {
["i:16202"] = 1/3, -- Greater Eternal Essence
},
["i:16202"] = {
["i:16203"] = 3, -- Lesser Eternal Essence
},
["i:11175"] = TSM.IsWowClassic() and {
["i:11174"] = 1/3, -- Greater Nether Essence
} or nil,
["i:11174"] = TSM.IsWowClassic() and {
["i:11175"] = 3, -- Lesser Nether Essence
} or nil,
["i:11135"] = TSM.IsWowClassic() and {
["i:11134"] = 1/3, -- Greater Mystic Essence
} or nil,
["i:11134"] = TSM.IsWowClassic() and {
["i:11135"] = 3, -- Lesser Mystic Essence
} or nil,
["i:11082"] = TSM.IsWowClassic() and {
["i:10998"] = 1/3, -- Greater Astral Essence
} or nil,
["i:10998"] = TSM.IsWowClassic() and {
["i:11082"] = 3, -- Lesser Astral Essence
} or nil,
["i:10939"] = {
["i:10938"] = 1/3, -- Greater Magic Essence
},
["i:10938"] = {
["i:10939"] = 3, -- Lesser Magic Essence
},
["i:16204"] = {
["i:156930"] = 1/3, -- Rich Illusion Dust
},
["i:156930"] = {
["i:16204"] = 3, -- Light Illusion Dust
},
-- ============================================ Shards =========================================
["i:52721"] = {
["i:52720"] = 1/3, -- Heavenly Shard
},
["i:34052"] = {
["i:34053"] = 1/3, -- Dream Shard
},
["i:74247"] = {
["i:74252"] = 1/3, -- Ethereal Shard
},
["i:111245"] = {
["i:115502"] = 0.1, -- Luminous Shard
},
-- =========================================== Crystals ========================================
["i:113588"] = {
["i:115504"] = 0.1, -- Temporal Crystal
},
-- ======================================== Primals / Motes ====================================
["i:21885"] = {
["i:22578"] = 0.1, -- Water
},
["i:22456"] = {
["i:22577"] = 0.1, -- Shadow
},
["i:22457"] = {
["i:22576"] = 0.1, -- Mana
},
["i:21886"] = {
["i:22575"] = 0.1, -- Life
},
["i:21884"] = {
["i:22574"] = 0.1, -- Fire
},
["i:22452"] = {
["i:22573"] = 0.1, -- Earth
},
["i:22451"] = {
["i:22572"] = 0.1, -- Air
},
-- ===================================== Crystalized / Eternal =================================
["i:37700"] = {
["i:35623"] = 10, -- Air
},
["i:35623"] = {
["i:37700"] = 0.1, -- Air
},
["i:37701"] = {
["i:35624"] = 10, -- Earth
},
["i:35624"] = {
["i:37701"] = 0.1, -- Earth
},
["i:37702"] = {
["i:36860"] = 10, -- Fire
},
["i:36860"] = {
["i:37702"] = 0.1, -- Fire
},
["i:37703"] = {
["i:35627"] = 10, -- Shadow
},
["i:35627"] = {
["i:37703"] = 0.1, -- Shadow
},
["i:37704"] = {
["i:35625"] = 10, -- Life
},
["i:35625"] = {
["i:37704"] = 0.1, -- Life
},
["i:37705"] = {
["i:35622"] = 10, -- Water
},
["i:35622"] = {
["i:37705"] = 0.1, -- Water
},
-- ========================================= Wod Fish ==========================================
["i:109137"] = {
["i:111601"] = 4, -- Enormous Crescent Saberfish
["i:111595"] = 2, -- Crescent Saberfish
["i:111589"] = 1, -- Small Crescent Saberfish
},
["i:109138"] = {
["i:111676"] = 4, -- Enormous Jawless Skulker
["i:111669"] = 2, -- Jawless Skulker
["i:111650"] = 1, -- Small Jawless Skulker
},
["i:109139"] = {
["i:111675"] = 4, -- Enormous Fat Sleeper
["i:111668"] = 2, -- Fat Sleeper
["i:111651"] = 1, -- Small Fat Sleeper
},
["i:109140"] = {
["i:111674"] = 4, -- Enormous Blind Lake Sturgeon
["i:111667"] = 2, -- Blind Lake Sturgeon
["i:111652"] = 1, -- Small Blind Lake Sturgeon
},
["i:109141"] = {
["i:111673"] = 4, -- Enormous Fire Ammonite
["i:111666"] = 2, -- Fire Ammonite
["i:111656"] = 1, -- Small Fire Ammonite
},
["i:109142"] = {
["i:111672"] = 4, -- Enormous Sea Scorpion
["i:111665"] = 2, -- Sea Scorpion
["i:111658"] = 1, -- Small Sea Scorpion
},
["i:109143"] = {
["i:111671"] = 4, -- Enormous Abyssal Gulper Eel
["i:111664"] = 2, -- Abyssal Gulper Eel
["i:111659"] = 1, -- Small Abyssal Gulper Eel
},
["i:109144"] = {
["i:111670"] = 4, -- Enormous Blackwater Whiptail
["i:111663"] = 2, -- Blackwater Whiptail
["i:111662"] = 1, -- Small Blackwater Whiptail
},
-- ========================================== Aromatic Fish Oil (BFA) ===========================
["i:160711"] = {
["i:152543"] = 1, -- Sand Shifter
["i:152544"] = 1, -- Slimy Mackerel
["i:152545"] = 1, -- Frenzied Fangtooth
["i:152546"] = 1, -- Lane Snapper
["i:152547"] = 1, -- Great Sea Catfish
["i:152548"] = 1, -- Tiragarde Perch
["i:152549"] = 1, -- Redtail Loach
["i:168302"] = 1, -- Viper Fish
["i:168646"] = 1, -- Mauve Stinger
["i:174327"] = 1, -- Malformed Gnasher
["i:174328"] = 1, -- Aberrant Voidfin
},
-- ========================================== Ore Nuggets =======================================
["i:2771"] = {
["i:108295"] = 0.1, -- Tin Ore
},
["i:2772"] = {
["i:108297"] = 0.1, -- Iron Ore
},
["i:2775"] = {
["i:108294"] = 0.1, -- Silver Ore
},
["i:2776"] = {
["i:108296"] = 0.1, -- Gold Ore
},
["i:3858"] = {
["i:108300"] = 0.1, -- Mithril Ore
},
["i:7911"] = {
["i:108299"] = 0.1, -- Truesilver Ore
},
["i:10620"] = {
["i:108298"] = 0.1, -- Thorium Ore
},
["i:23424"] = {
["i:108301"] = 0.1, -- Fel Iron Ore
},
["i:23425"] = {
["i:108302"] = 0.1, -- Adamantite Ore
},
["i:23426"] = {
["i:108304"] = 0.1, -- Khorium Ore
},
["i:23427"] = {
["i:108303"] = 0.1, -- Eternium Ore
},
["i:36909"] = {
["i:108305"] = 0.1, -- Cobalt Ore
},
["i:36910"] = {
["i:108391"] = 0.1, -- Titanium Ore
},
["i:36912"] = {
["i:108306"] = 0.1, -- Saronite Ore
},
["i:52183"] = {
["i:108309"] = 0.1, -- Pyrite Ore
},
["i:52185"] = {
["i:108308"] = 0.1, -- Elementium Ore
},
["i:53038"] = {
["i:108307"] = 0.1, -- Obsidium Ore
},
["i:72092"] = {
["i:97512"] = 0.1, -- Ghost Iron Ore
},
["i:109119"] = {
["i:109991"] = 0.1, -- True Iron Ore
},
-- =========================================== Herb Parts ======================================
["i:2449"] = {
["i:108319"] = 0.1, -- Earthroot Stem
},
["i:2453"] = {
["i:108322"] = 0.1, -- Bruiseweed Stem
},
["i:3357"] = {
["i:108325"] = 0.1, -- Liferoot Stem
},
["i:3358"] = {
["i:108326"] = 0.1, -- Khadgar's Whisker Stem
},
["i:3819"] = {
["i:108329"] = 0.1, -- Dragon's Teeth Stem
},
["i:8839"] = {
["i:108336"] = 0.1, -- Blindweed Stem
},
["i:22792"] = {
["i:108350"] = 0.1, -- Nightmare Vine Stem
},
["i:36903"] = {
["i:108353"] = 0.1, -- Adder's Tongue Stem
},
["i:52985"] = {
["i:108362"] = 0.1, -- Azshara's Veil Stem
},
["i:52988"] = {
["i:108365"] = 0.1, -- Whiptail Stem
},
["i:72235"] = {
["i:97621"] = 0.1, -- Silkweed Stem
},
["i:109124"] = {
["i:109624"] = 0.1, -- Broken Frostweed Stem
},
["i:109125"] = {
["i:109625"] = 0.1, -- Broken Fireweed Stem
},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function Transform.TargetItemIterator()
return private.TableKeyIterator, DATA, nil
end
function Transform.SourceItemIterator(targetItemString)
return private.TableKeyIterator, DATA[targetItemString], nil
end
function Transform.GetRate(targetItemString, sourceItemString)
return DATA[targetItemString][sourceItemString]
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.TableKeyIterator(tbl, index)
index = next(tbl, index)
return index
end

215
LibTSM/Data/VendorSell.lua Normal file
View File

@@ -0,0 +1,215 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local VendorSell = TSM.Init("Data.VendorSell")
-- ============================================================================
-- Vendor Sell Data
-- ============================================================================
-- Scraped from Wowhead using the following javascript and then manually pruned to remove limited quantity items and fill in missing prices:
-- x = listviewitems.sort((a,b) => a.id - b.id); for (i=0; i<x.length; i++) console.log("[\"i:"+x[i].id+"\"] = "+x[i].cost[0]+", -- "+x[i].name);
local VENDOR_SELL_PRICES = TSM.IsWowClassic() and {
-- https://classic.wowhead.com/items/side:3?filter=92:87:186:86;1:11:1:12;0:0:0:0
["i:159"] = 25, -- Refreshing Spring Water
["i:1179"] = 125, -- Ice Cold Milk
["i:2320"] = 10, -- Coarse Thread
["i:2321"] = 100, -- Fine Thread
["i:2324"] = 25, -- Bleach
["i:2325"] = 1000, -- Black Dye
["i:2596"] = 120, -- Skin of Dwarven Stout
["i:2604"] = 50, -- Red Dye
["i:2605"] = 100, -- Green Dye
["i:2665"] = 20, -- Stormwind Seasoning Herbs
["i:2678"] = 10, -- Mild Spices
["i:2692"] = 40, -- Hot Spices
["i:2880"] = 100, -- Weak Flux
["i:2894"] = 50, -- Rhapsody Malt
["i:3371"] = 20, -- Empty Vial
["i:3372"] = 200, -- Leaded Vial
["i:3466"] = 2000, -- Strong Flux
["i:3713"] = 160, -- Soothing Spices
["i:3857"] = 500, -- Coal
["i:4289"] = 50, -- Salt
["i:4291"] = 500, -- Silken Thread
["i:4340"] = 350, -- Gray Dye
["i:4341"] = 500, -- Yellow Dye
["i:4342"] = 2500, -- Purple Dye
["i:4399"] = 200, -- Wooden Stock
["i:4400"] = 2000, -- Heavy Stock
["i:4470"] = 38, -- Simple Wood
["i:4536"] = 25, -- Shiny Red Apple
["i:5140"] = 25, -- Flash Powder
["i:6217"] = 124, -- Copper Rod
["i:6260"] = 50, -- Blue Dye
["i:6261"] = 1000, -- Orange Dye
["i:6530"] = 100, -- Nightcrawlers
["i:8343"] = 2000, -- Heavy Silken Thread
["i:8925"] = 2500, -- Crystal Vial
["i:10290"] = 2500, -- Pink Dye
["i:10647"] = 2000, -- Engineer's Ink
["i:10648"] = 500, -- Blank Parchment
["i:11291"] = 4500, -- Star Wood
["i:14341"] = 5000, -- Rune Thread
["i:16583"] = 10000, -- Demonic Figurine
["i:17020"] = 1000, -- Arcane Powder
["i:17021"] = 700, -- Wild Berries
["i:17026"] = 1000, -- Wild Thornroot
["i:17028"] = 700, -- Holy Candle
["i:17029"] = 1000, -- Sacred Candle
["i:17030"] = 2000, -- Ankh
["i:17031"] = 1000, -- Rune of Teleportation
["i:17032"] = 2000, -- Rune of Portals
["i:17033"] = 2000, -- Symbol of Divinity
["i:17034"] = 200, -- Maple Seed
["i:17035"] = 400, -- Stranglethorn Seed
["i:17036"] = 800, -- Ashwood Seed
["i:17037"] = 1400, -- Hornbeam Seed
["i:17038"] = 2000, -- Ironwood Seed
["i:17194"] = 10, -- Holiday Spices
["i:17196"] = 50, -- Holiday Spirits
["i:17202"] = 10, -- Snowball
["i:18256"] = 30000, -- Imbued Vial
["i:18567"] = 150000, -- Elemental Flux
["i:21177"] = 3000, -- Symbol of Kings
} or {
-- https://www.wowhead.com/items/side:3/live-only:on?filter=92:87:186:86;1:11:1:12;0:0:0:0
["i:159"] = 25, -- Refreshing Spring Water
["i:1179"] = 125, -- Ice Cold Milk
["i:2320"] = 10, -- Coarse Thread
["i:2321"] = 100, -- Fine Thread
["i:2324"] = 25, -- Bleach
["i:2325"] = 1000, -- Black Dye
["i:2593"] = 150, -- Flask of Stormwind Tawny
["i:2594"] = 1500, -- Flagon of Dwarven Mead
["i:2595"] = 2000, -- Jug of Badlands Bourbon
["i:2596"] = 120, -- Skin of Dwarven Stout
["i:2604"] = 50, -- Red Dye
["i:2605"] = 100, -- Green Dye
["i:2678"] = 10, -- Mild Spices
["i:2880"] = 100, -- Weak Flux
["i:2901"] = 81, -- Mining Pick
["i:3371"] = 150, -- Crystal Vial
["i:3466"] = 2000, -- Strong Flux
["i:3857"] = 500, -- Coal
["i:4289"] = 50, -- Salt
["i:4291"] = 500, -- Silken Thread
["i:4340"] = 350, -- Gray Dye
["i:4341"] = 500, -- Yellow Dye
["i:4342"] = 2500, -- Purple Dye
["i:4399"] = 200, -- Wooden Stock
["i:4400"] = 2000, -- Heavy Stock
["i:4470"] = 38, -- Simple Wood
["i:4537"] = 125, -- Tel'Abim Banana
["i:5956"] = 18, -- Blacksmith Hammer
["i:6217"] = 124, -- Copper Rod
["i:6260"] = 50, -- Blue Dye
["i:6261"] = 1000, -- Orange Dye
["i:6530"] = 100, -- Nightcrawlers
["i:7005"] = 82, -- Skinning Knife
["i:8343"] = 2000, -- Heavy Silken Thread
["i:10290"] = 2500, -- Pink Dye
["i:10647"] = 2000, -- Engineer's Ink
["i:11291"] = 4500, -- Star Wood
["i:14341"] = 5000, -- Rune Thread
["i:17194"] = 10, -- Holiday Spices
["i:17196"] = 50, -- Holiday Spirits
["i:17202"] = 10, -- Snowball
["i:18567"] = 30000, -- Elemental Flux
["i:23572"] = 500000, -- Primal Nether
["i:27860"] = 6400, -- Purified Draenic Water
["i:30183"] = 700000, -- Nether Vortex
["i:30817"] = 25, -- Simple Flour
["i:34249"] = 1000000, -- Hula Girl Doll
["i:34412"] = 1000, -- Sparkling Apple Cider
["i:35948"] = 16000, -- Savory Snowplum
["i:35949"] = 8500, -- Tundra Berries
["i:38426"] = 30000, -- Eternium Thread
["i:39354"] = 15, -- Light Parchment
["i:39684"] = 9000, -- Hair Trigger
["i:40533"] = 50000, -- Walnut Stock
["i:43102"] = 750000, -- Frozen Orb
["i:44499"] = 30000000, -- Salvaged Iron Golem Parts
["i:44500"] = 15000000, -- Elementium-Plated Exhaust Pipe
["i:44501"] = 10000000, -- Goblin-Machined Piston
["i:44835"] = 10, -- Autumnal Herbs
["i:44853"] = 25, -- Honey
["i:44854"] = 25, -- Tangy Wetland Cranberries
["i:44855"] = 25, -- Teldrassil Sweet Potato
["i:46784"] = 25, -- Ripe Elwynn Pumpkin
["i:46793"] = 25, -- Tangy Southfury Cranberries
["i:46796"] = 25, -- Ripe Tirisfal Pumpkin
["i:46797"] = 25, -- Mulgore Sweet Potato
["i:49908"] = 1500000, -- Primordial Saronite
["i:52188"] = 15000, -- Jeweler's Setting
["i:58265"] = 20000, -- Highland Pomegranate
["i:58278"] = 16000, -- Tropical Sunfruit
["i:62323"] = 60000, -- Deathwing Scale Fragment
["i:65892"] = 50000000, -- Pyrium-Laced Crystalline Vial
["i:65893"] = 30000000, -- Sands of Time
["i:67319"] = 328990, -- Preserved Ogre Eye
["i:67335"] = 445561, -- Silver Charm Bracelet
["i:74659"] = 30000, -- Farm Chicken
["i:74660"] = 15000, -- Pandaren Peach
["i:74832"] = 12000, -- Barley
["i:74845"] = 35000, -- Ginseng
["i:74851"] = 14000, -- Rice
["i:74852"] = 16000, -- Yak Milk
["i:74854"] = 7000, -- Instant Noodles
["i:79740"] = 23, -- Plain Wooden Staff
["i:80433"] = 2000000, -- Blood Spirit
["i:83092"] = 200000000, -- Orb of Mystery
["i:85583"] = 12000, -- Needle Mushrooms
["i:85584"] = 17000, -- Silkworm Pupa
["i:85585"] = 27000, -- Red Beans
["i:102539"] = 5000, -- Fresh Strawberries
["i:102540"] = 5000, -- Fresh Mangos
["i:124436"] = 40000, -- Foxflower Flux
["i:127037"] = 5000, -- Runic Catgut
["i:127681"] = 5000, -- Sharp Spritethorn
["i:133588"] = 25000, -- Flaked Sea Salt
["i:133589"] = 25000, -- Dalapeño Pepper
["i:133590"] = 25000, -- Muskenbutter
["i:133591"] = 25000, -- River Onion
["i:133592"] = 25000, -- Stonedark Snail
["i:133593"] = 25000, -- Royal Olive
["i:136629"] = 173300, -- Felgibber Shotgun
["i:136630"] = 118500, -- "Twirling Bottom" Repeater
["i:136631"] = 450000, -- Surface-to-Infernal Rocket Launcher
["i:136632"] = 210800, -- Chaos Blaster
["i:136633"] = 25000, -- Loose Trigger
["i:136636"] = 57500, -- Sniping Scope
["i:136637"] = 11500, -- Oversized Blasting Cap
["i:136638"] = 89500, -- True Iron Barrel
["i:158186"] = 250, -- Distilled Water
["i:158205"] = 1000, -- Acacia Powder
["i:159959"] = 6000, -- Nylon Thread
["i:160059"] = 250, -- Amber Tanning Oil
["i:160298"] = 3000, -- Durable Flux
["i:160398"] = 25000, -- Choral Honey
["i:160399"] = 25000, -- Wild Flour
["i:160400"] = 25000, -- Foosaka
["i:160502"] = 11500, -- Chemical Blasting Cap
["i:160705"] = 50, -- Major's Frothy Coffee
["i:160709"] = 25000, -- Fresh Potato
["i:160710"] = 25000, -- Wild Berries
["i:160712"] = 25000, -- Powdered Sugar
["i:161131"] = 300000000, -- Barely Stable Azerite Reactor
["i:163569"] = 100, -- Insulated Wiring
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function VendorSell.Iterator()
return pairs(VENDOR_SELL_PRICES)
end

View File

@@ -0,0 +1,92 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local VendorTrade = TSM.Init("Data.VendorTrade")
local private = {}
-- ============================================================================
-- Vendor Trade Data
-- ============================================================================
local DATA = TSM.IsWowClassic() and {} or {
["i:37101"] = {
["i:173058"] = 1, -- Ivory Ink
},
["i:39469"] = {
["i:173058"] = 1, -- Moonglow Ink
},
["i:39774"] = {
["i:173058"] = 1, -- Midnight Ink
},
["i:43116"] = {
["i:173058"] = 1, -- Lion's Ink
},
["i:43118"] = {
["i:173058"] = 1, -- Jadefire Ink
},
["i:43120"] = {
["i:173058"] = 1, -- Celestial Ink
},
["i:43122"] = {
["i:173058"] = 1, -- Shimmering Ink
},
["i:43124"] = {
["i:173058"] = 1, -- Ethereal Ink
},
["i:43126"] = {
["i:173058"] = 1, -- Ink of the Sea
},
["i:43127"] = {
["i:173058"] = 0.1, -- Snowfall Ink
},
["i:61978"] = {
["i:173058"] = 1, -- Blackfallow Ink
},
["i:61981"] = {
["i:173058"] = 0.1, -- Inferno Ink
},
["i:79254"] = {
["i:173058"] = 1, -- Ink of Dreams
},
["i:79255"] = {
["i:173058"] = 0.1, -- Starlight Ink
},
["i:113111"] = {
["i:173058"] = 1, -- Warbinder's Ink
},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function VendorTrade.TargetItemIterator()
return private.TableKeyIterator, DATA, nil
end
function VendorTrade.SourceItemIterator(targetItemString)
return private.TableKeyIterator, DATA[targetItemString], nil
end
function VendorTrade.GetRate(targetItemString, sourceItemString)
return DATA[targetItemString][sourceItemString]
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.TableKeyIterator(tbl, index)
index = next(tbl, index)
return index
end

View File

@@ -0,0 +1,74 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local AltTracking = TSM.Init("Service.AltTracking")
local Database = TSM.Include("Util.Database")
local TempTable = TSM.Include("Util.TempTable")
local Vararg = TSM.Include("Util.Vararg")
local Settings = TSM.Include("Service.Settings")
local private = {
quantityDB = nil,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
AltTracking:OnSettingsLoad(function()
private.quantityDB = Database.NewSchema("INVENTORY_ALT_QUANTITY")
:AddUniqueStringField("itemString")
:AddNumberField("quantity")
:Commit()
local altItemQuantity = TempTable.Acquire()
for _, factionrealm, character, syncScopeKey in Settings.ConnectedFactionrealmAltCharacterIterator() do
for _, key in Vararg.Iterator("bagQuantity", "bankQuantity", "reagentBankQuantity", "auctionQuantity", "mailQuantity") do
for itemString, quantity in pairs(Settings.Get("sync", syncScopeKey, "internalData", key)) do
altItemQuantity[itemString] = (altItemQuantity[itemString] or 0) + quantity
end
end
local pendingMailLookup = Settings.Get("factionrealm", factionrealm, "internalData", "pendingMail")
local pendingMail = pendingMailLookup[character]
local isValid = true
if pendingMail then
for itemString, quantity in pairs(pendingMail) do
if type(quantity) ~= "number" then
isValid = false
break
end
altItemQuantity[itemString] = (altItemQuantity[itemString] or 0) + quantity
end
end
if not isValid then
pendingMailLookup[character] = nil
end
end
private.quantityDB:BulkInsertStart()
for itemString, quantity in pairs(altItemQuantity) do
private.quantityDB:BulkInsertNewRow(itemString, quantity)
end
private.quantityDB:BulkInsertEnd()
TempTable.Release(altItemQuantity)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function AltTracking.BaseItemIterator()
return private.quantityDB:NewQuery()
:Select("itemString")
:IteratorAndRelease()
end
function AltTracking.GetQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "quantity") or 0
end

View File

@@ -0,0 +1,734 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local AuctionHouseWrapper = TSM.Init("Service.AuctionHouseWrapper")
local LibTSMClass = TSM.Include("LibTSMClass")
local Log = TSM.Include("Util.Log")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local Table = TSM.Include("Util.Table")
local Future = TSM.Include("Util.Future")
local Vararg = TSM.Include("Util.Vararg")
local Analytics = TSM.Include("Util.Analytics")
local Math = TSM.Include("Util.Math")
local Debug = TSM.Include("Util.Debug")
local APIWrapper = LibTSMClass.DefineClass("APIWrapper")
local private = {
wrappers = {},
events = {},
argsTemp = {},
sortsPartsTemp = {},
itemKeyPartsTemp = {},
searchQueryAPITimes = {},
isAHOpen = false,
lastResponseReceived = 0,
hookedTime = {},
lastAuctionCanceledAuctionId = nil,
lastAuctionCanceledTime = 0,
auctionIdUpdateCallbacks = {},
}
local API_TIMEOUT = 5
local GET_ALL_TIMEOUT = 30
local SEARCH_QUERY_THROTTLE_INTERVAL = 60
local SEARCH_QUERY_THROTTLE_MAX = 100
local EMPTY_SORTS_TABLE = {}
local ITEM_KEY_KEYS = {
"itemID",
"itemLevel",
"itemSuffix",
"battlePetSpeciesID",
}
local SILENT_EVENTS = {
AUCTION_ITEM_LIST_UPDATE = true,
REPLICATE_ITEM_LIST_UPDATE = true,
}
local GENERIC_EVENTS = {
CHAT_MSG_SYSTEM = 1,
UI_ERROR_MESSAGE = 2,
}
local GENERIC_EVENT_SEP = "/"
local API_EVENT_INFO = TSM.IsWowClassic() and
{ -- Classic
QueryAuctionItems = {
AUCTION_ITEM_LIST_UPDATE = { result = true },
},
} or
{ -- Retail
SendBrowseQuery = {
AUCTION_HOUSE_BROWSE_RESULTS_UPDATED = { result = true },
},
SearchForFavorites = {
AUCTION_HOUSE_BROWSE_RESULTS_UPDATED = { result = true },
},
SearchForItemKeys = {
AUCTION_HOUSE_BROWSE_RESULTS_UPDATED = { result = true },
},
ReplicateItems = {
REPLICATE_ITEM_LIST_UPDATE = { result = true },
},
RequestMoreBrowseResults = {
AUCTION_HOUSE_BROWSE_RESULTS_ADDED = { result = 1 },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { timeoutChange = 1 },
},
SendSearchQuery = {
COMMODITY_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1, apiArgKey = "itemID" },
ITEM_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1 },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { timeoutChange = 1 },
},
SendSellSearchQuery = {
COMMODITY_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1, apiArgKey = "itemID" },
ITEM_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1 },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { timeoutChange = 1 },
},
RequestMoreCommoditySearchResults = {
COMMODITY_SEARCH_RESULTS_ADDED = { result = true },
},
RequestMoreItemSearchResults = {
ITEM_SEARCH_RESULTS_ADDED = { result = true },
},
RefreshCommoditySearchResults = {
COMMODITY_SEARCH_RESULTS_UPDATED = { result = true },
},
RefreshItemSearchResults = {
ITEM_SEARCH_RESULTS_UPDATED = { result = true },
},
QueryOwnedAuctions = {
OWNED_AUCTIONS_UPDATED = { result = true },
},
QueryBids = {
BIDS_UPDATED = { result = true },
},
CancelAuction = {
AUCTION_CANCELED = { result = true, eventArgIndex = 1, apiArgIndex = 1, compareFunc = function(eventArg, apiArg) return eventArg == 0 or apiArg == eventArg end },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
},
StartCommoditiesPurchase = {
COMMODITY_PRICE_UPDATED = { result = 2, rawFilterFunc = function(apiArgs, unitPrice, totalPrice) return Math.Ceil((totalPrice / apiArgs[2]), COPPER_PER_SILVER) == apiArgs[3] and true end },
COMMODITY_PRICE_UNAVAILABLE = { result = false },
},
ConfirmCommoditiesPurchase = {
COMMODITY_PURCHASE_SUCCEEDED = { result = true },
COMMODITY_PURCHASE_FAILED = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_HIGHER_BID] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BID_OWN] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = false },
},
PlaceBid = {
AUCTION_CANCELED = { result = true, eventArgIndex = 1, apiArgIndex = 1 },
["CHAT_MSG_SYSTEM"..GENERIC_EVENT_SEP..ERR_AUCTION_BID_PLACED] = { result = true },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_HIGHER_BID] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BID_OWN] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_MAX_COUNT] = { result = false },
},
PostItem = {
AUCTION_HOUSE_AUCTION_CREATED = { result = true, rawFilterFunc = function(apiArgs) return apiArgs[3] <= 1 end },
AUCTION_MULTISELL_UPDATE = { result = true, rawFilterFunc = function(apiArgs, createdCount, totalToCreate) return createdCount == totalToCreate end },
AUCTION_MULTISELL_FAILURE = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_REPAIR_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_LIMITED_DURATION_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_USED_CHARGES] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_WRAPPED_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BAG] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = nil },
},
PostCommodity = {
AUCTION_HOUSE_AUCTION_CREATED = { result = true },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_REPAIR_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_LIMITED_DURATION_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_USED_CHARGES] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_WRAPPED_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BAG] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = nil },
},
}
-- ============================================================================
-- Module Loading
-- ============================================================================
AuctionHouseWrapper:OnModuleLoad(function()
Event.Register("AUCTION_HOUSE_SHOW", private.AuctionHouseShowHandler)
Event.Register("AUCTION_HOUSE_CLOSED", private.AuctionHouseClosedHandler)
-- setup wrappers
for apiName in pairs(API_EVENT_INFO) do
private.wrappers[apiName] = APIWrapper(apiName)
end
if not TSM.IsWowClassic() then
-- extra hooks to track search query calls since they are limited
hooksecurefunc(C_AuctionHouse, "SendSearchQuery", function()
tinsert(private.searchQueryAPITimes, GetTime())
end)
hooksecurefunc(C_AuctionHouse, "SendSellSearchQuery", function()
tinsert(private.searchQueryAPITimes, GetTime())
end)
-- events to track auction purchases
Event.Register("AUCTION_CANCELED", private.AuctionCanceledHandler)
Event.Register("ITEM_SEARCH_RESULTS_UPDATED", private.ItemSearchResultsUpdated)
-- general events
Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_RESPONSE_RECEIVED", private.ResponseReceivedHandler)
-- extra events that are interesting to log
Event.Register("AUCTION_HOUSE_NEW_RESULTS_RECEIVED", private.UnusedEventHandler)
Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_DROPPED", private.UnusedEventHandler)
Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_QUEUED", private.UnusedEventHandler)
Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_SENT", private.UnusedEventHandler)
if not TSM.IsShadowlands() then
Event.Register("AUCTION_HOUSE_THROTTLED_SPECIFIC_SEARCH_READY", private.UnusedEventHandler)
end
Event.Register("AUCTION_HOUSE_THROTTLED_SYSTEM_READY", private.UnusedEventHandler)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function AuctionHouseWrapper.RegisterAuctionIdUpdateCallback(callback)
tinsert(private.auctionIdUpdateCallbacks, callback)
end
function AuctionHouseWrapper.IsOpen()
return private.isAHOpen
end
function AuctionHouseWrapper.GetAndResetTotalHookedTime()
local total, topTime, topAddon = 0, nil, nil
for addon, hookedTime in pairs(private.hookedTime) do
total = total + hookedTime
if hookedTime > (topTime or 0) then
topTime = hookedTime
topAddon = addon
end
end
wipe(private.hookedTime)
return total, topAddon, topTime
end
function AuctionHouseWrapper.SendBrowseQuery(query)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.SendBrowseQuery:Start(query)
end
function AuctionHouseWrapper.RequestMoreBrowseResults()
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.RequestMoreBrowseResults:Start()
end
function AuctionHouseWrapper.SendSearchQuery(itemKey, isSell)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
-- remove times which are beyond the throttle interval
for i = #private.searchQueryAPITimes, 1, -1 do
if GetTime() - private.searchQueryAPITimes[i] >= SEARCH_QUERY_THROTTLE_INTERVAL then
tremove(private.searchQueryAPITimes, i)
end
end
if #private.searchQueryAPITimes >= SEARCH_QUERY_THROTTLE_MAX then
local delayTime = private.searchQueryAPITimes[1] + SEARCH_QUERY_THROTTLE_INTERVAL - GetTime()
assert(delayTime > 0, "Invalid delay time: "..tostring(delayTime))
Log.Err("Search query can't be run for another %.3f seconds", delayTime)
return nil, delayTime
end
assert(type(isSell) == "boolean")
if isSell then
return private.wrappers.SendSellSearchQuery:Start(itemKey, EMPTY_SORTS_TABLE, true)
else
return private.wrappers.SendSearchQuery:Start(itemKey, EMPTY_SORTS_TABLE, true)
end
end
function AuctionHouseWrapper.RequestMoreCommoditySearchResults(itemId)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.RequestMoreCommoditySearchResults:Start(itemId)
end
function AuctionHouseWrapper.RequestMoreItemSearchResults(itemKey)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.RequestMoreItemSearchResults:Start(itemKey)
end
function AuctionHouseWrapper.QueryOwnedAuctions(sorts)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.QueryOwnedAuctions:Start(sorts)
end
function AuctionHouseWrapper.CancelAuction(auctionId)
assert(not TSM.IsWowClassic())
-- if QueryOwnedAuctions is pending, just cancel it
private.wrappers.QueryOwnedAuctions:CancelIfPending()
if not private.CheckAllIdle() then
return
end
return private.wrappers.CancelAuction:Start(auctionId)
end
function AuctionHouseWrapper.StartCommoditiesPurchase(itemId, quantity, itemBuyout)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.StartCommoditiesPurchase:Start(itemId, quantity, itemBuyout)
end
function AuctionHouseWrapper.ConfirmCommoditiesPurchase(itemId, quantity, totalBuyout)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.ConfirmCommoditiesPurchase:Start(itemId, quantity, totalBuyout)
end
function AuctionHouseWrapper.PlaceBid(auctionId, bidBuyout)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.PlaceBid:Start(auctionId, bidBuyout)
end
function AuctionHouseWrapper.PostItem(itemLocation, postTime, stackSize, bid, buyout)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.PostItem:Start(itemLocation, postTime, stackSize, bid, buyout)
end
function AuctionHouseWrapper.PostCommodity(itemLocation, postTime, stackSize, itemBuyout)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.PostCommodity:Start(itemLocation, postTime, stackSize, itemBuyout)
end
function AuctionHouseWrapper.QueryAuctionItems(name, minLevel, maxLevel, page, usable, quality, getAll, exact, filterData)
assert(TSM.IsWowClassic())
local canSendQuery, canSendGetAll = CanSendAuctionQuery()
if not canSendQuery or (getAll and not canSendGetAll) or not private.CheckAllIdle() then
return
end
return private.wrappers.QueryAuctionItems:Start(name, minLevel, maxLevel, page, usable, quality, getAll, exact, filterData)
end
-- ============================================================================
-- APIWrapper Class
-- ============================================================================
function APIWrapper.__init(self, name)
self._name = name
self._args = {}
self._state = "IDLE"
self._callTime = nil
self._future = Future.New(self._name.."_FUTURE")
self._future:SetScript("OnCleanup", function()
if self._state == "PENDING_REQUESTED" then
-- switch the current call to a hooked call
self._state = "PENDING_HOOKED"
elseif self._state == "DONE" then
self._state = "IDLE"
end
end)
self._timeoutWrapper = function()
Log.Err("API timed out: %s(%s)", self._name, private.ArgsToStr(unpack(self._args)))
return self:_Done(false)
end
-- hook the API
hooksecurefunc(TSM.IsWowClassic() and _G or C_AuctionHouse, self._name, function(...)
Log.Info("%s(%s)", self._name, private.ArgsToStr(...))
if self:_IsPending() and select("#", ...) == 0 then
return
end
self:CancelIfPending()
if self:_HandleAPICall(...) then
for _, wrapper in pairs(private.wrappers) do
if wrapper ~= self and GetTime() ~= private.lastResponseReceived then
wrapper:CancelIfPending()
end
end
end
end)
-- register related events
for eventName in pairs(API_EVENT_INFO[self._name]) do
private.RegisterForEvent(eventName, self)
end
end
function APIWrapper.IsIdle(self)
return self._state == "IDLE"
end
function APIWrapper.CancelIfPending(self)
if not self:_IsPending() then
return
end
Log.Warn("Canceling pending (%s, %s)", self._name, self._state)
self:_Done(false)
end
function APIWrapper.Start(self, ...)
if self._state ~= "IDLE" then
Log.Err("API already in progress (%s)", self._name)
return
end
self._state = "STARTING"
self:_CallAPI(...)
return self._future
end
function APIWrapper._IsPending(self)
return self._state == "PENDING_REQUESTED" or self._state == "PENDING_HOOKED"
end
function APIWrapper._CallAPI(self, ...)
return (TSM.IsWowClassic() and _G or C_AuctionHouse)[self._name](...)
end
function APIWrapper._HandleAPICall(self, ...)
self._callTime = GetTime()
if self._state == "IDLE" then
self._state = "PENDING_HOOKED"
self._hookAddon = strmatch(Debug.GetStackLevelLocation(3), "AddOns\\([^\\]+)\\")
elseif self._state == "STARTING" then
self._future:Start()
self._state = "PENDING_REQUESTED"
else
error("Unexpected state: "..self._state)
end
Vararg.IntoTable(self._args, ...)
local timeout = nil
if not private.isAHOpen then
timeout = 0
elseif self._name == "QueryAuctionItems" and select(7, ...) then
timeout = GET_ALL_TIMEOUT
else
timeout = API_TIMEOUT
end
Delay.AfterTime(self._name.."_TIMEOUT", timeout, self._timeoutWrapper)
return true
end
function APIWrapper._HandleEvent(self, eventName, ...)
if self._state ~= "PENDING_REQUESTED" and self._state ~= "PENDING_HOOKED" then
return
end
local eventIsValid, result = self:_ValidateEvent(eventName, ...)
if not eventIsValid then
Log.Info("Ignoring invalidated event")
return
end
self:_Done(result)
end
function APIWrapper._ValidateEvent(self, eventName, ...)
local info = nil
if GENERIC_EVENTS[eventName] then
local arg = ...
info = API_EVENT_INFO[self._name][eventName..GENERIC_EVENT_SEP..arg]
else
info = API_EVENT_INFO[self._name][eventName]
end
assert(info)
if info.timeoutChange then
Delay.Cancel(self._name.."_TIMEOUT")
Delay.AfterTime(self._name.."_TIMEOUT", info.timeoutChange, self._timeoutWrapper)
return false
end
local eventIsValid, result = true, nil
if type(info.result) == "number" then
result = select(info.result, ...)
else
result = info.result
end
if info.rawFilterFunc then
if not info.rawFilterFunc(self._args, ...) then
eventIsValid = false
end
elseif info.eventArgIndex then
local eventValue = select(info.eventArgIndex, ...)
local apiValue = self._args[info.apiArgIndex]
if info.apiArgKey then
apiValue = apiValue[info.apiArgKey]
end
local argMatches = nil
assert(type(eventValue) == type(apiValue))
if info.compareFunc then
argMatches = info.compareFunc(eventValue, apiValue)
elseif private.IsItemKey(eventValue) then
argMatches = true
for _, key in ipairs(ITEM_KEY_KEYS) do
if eventValue[key] ~= apiValue[key] then
argMatches = false
break
end
end
elseif type(eventValue) == "table" then
argMatches = Table.Equal(eventValue, apiValue)
else
argMatches = eventValue == apiValue
end
if not argMatches then
eventIsValid = false
end
end
return eventIsValid, result
end
function APIWrapper._Done(self, result)
wipe(self._args)
local hookAddon = self._hookAddon
self._hookAddon = nil
local totalTime = Math.Round((GetTime() - (self._callTime or GetTime())) * 1000)
self._callTime = nil
Delay.Cancel(self._name.."_TIMEOUT")
if self._state == "PENDING_REQUESTED" then
if totalTime > 0 then
Analytics.Action("AH_API_TIME", private.GetAnalyticsRegionRealm(), self._name, result and totalTime or -1)
end
self._state = "DONE"
-- need to do this last as it might trigger another API call or OnCleanup on the future
self._future:Done(result)
elseif self._state == "PENDING_HOOKED" then
self._state = "IDLE"
if hookAddon then
private.hookedTime[hookAddon] = (private.hookedTime[hookAddon] or 0) + totalTime / 1000
end
else
error("Unexpected state: "..self._state)
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.AuctionHouseShowHandler()
private.isAHOpen = true
end
function private.AuctionHouseClosedHandler()
private.isAHOpen = false
for _, wrapper in pairs(private.wrappers) do
wrapper:CancelIfPending()
end
end
function private.IsItemKey(value)
if type(value) ~= "table" then
return false
end
for _, key in ipairs(ITEM_KEY_KEYS) do
if not value[key] then
return false
end
end
return true
end
function private.ItemKeyToStr(itemKey)
assert(#private.itemKeyPartsTemp == 0)
if itemKey.itemID ~= 0 then
tinsert(private.itemKeyPartsTemp, "itemID="..itemKey.itemID)
end
if itemKey.itemLevel ~= 0 then
tinsert(private.itemKeyPartsTemp, "itemLevel="..itemKey.itemLevel)
end
if itemKey.itemSuffix ~= 0 then
tinsert(private.itemKeyPartsTemp, "itemSuffix="..itemKey.itemSuffix)
end
if itemKey.battlePetSpeciesID ~= 0 then
tinsert(private.itemKeyPartsTemp, "battlePetSpeciesID="..itemKey.battlePetSpeciesID)
end
local result = format("{%s}", table.concat(private.itemKeyPartsTemp, ","))
wipe(private.itemKeyPartsTemp)
return result
end
function private.SortsToStr(sorts)
assert(#private.sortsPartsTemp == 0)
for _, sort in ipairs(sorts) do
local name = Table.KeyByValue(Enum.AuctionHouseSortOrder, sort.sortOrder) or "?"
tinsert(private.sortsPartsTemp, format("%s%s", sort.reverseSort and "-" or "", name))
end
local result = format("{%s}", table.concat(private.sortsPartsTemp, ","))
wipe(private.sortsPartsTemp)
return result
end
function private.ArgToStr(arg)
if type(arg) == "table" then
local count = Table.Count(arg)
if private.IsItemKey(arg) then
return private.ItemKeyToStr(arg)
elseif arg.searchString then
return format("{searchString=\"%s\", sorts=%s, minLevel=%s, maxLevel=%s, filters=%s, itemClassFilters=%s}", arg.searchString, private.SortsToStr(arg.sorts), private.ArgToStr(arg.minLevel), private.ArgToStr(arg.maxLevel), private.ArgToStr(arg.filters), private.ArgToStr(arg.itemClassFilters))
elseif arg.IsBagAndSlot then
return format("{<ItemLocation:(%d,%d)>}", arg:GetBagAndSlot())
elseif count == 0 then
return "{}"
elseif count == #arg then
if type(arg[1]) == "table" and arg[1].sortOrder then
return format("{sorts=%s}", private.SortsToStr(arg))
end
return format("{<%d items>}", count)
else
return "{...}"
end
else
return tostring(arg)
end
end
function private.ArgsToStr(...)
assert(#private.argsTemp == 0)
for _, arg in Vararg.Iterator(...) do
tinsert(private.argsTemp, private.ArgToStr(arg))
end
local result = table.concat(private.argsTemp, ",")
wipe(private.argsTemp)
return result
end
function private.RegisterForEvent(eventName, wrapper)
local genericEventArg = nil
eventName, genericEventArg = strsplit(GENERIC_EVENT_SEP, eventName)
if not private.events[eventName] then
private.events[eventName] = {}
Event.Register(eventName, private.EventHandler)
end
if genericEventArg then
private.events[eventName][genericEventArg] = private.events[eventName][genericEventArg] or {}
tinsert(private.events[eventName][genericEventArg], wrapper)
else
tinsert(private.events[eventName], wrapper)
end
end
function private.EventHandler(eventName, ...)
-- reduce the log spam of generic events by combining the message with the name and discarding arguments
local genericEventArg = nil
if eventName == "UI_ERROR_MESSAGE" and select(1, ...) == ERR_AUCTION_DATABASE_ERROR then
-- log an analytics event for "Internal Auction Error" messages
for apiName, wrapper in pairs(private.wrappers) do
if not wrapper:IsIdle() then
Analytics.Action("AH_INTERNAL_ERROR", private.GetAnalyticsRegionRealm(), apiName)
break
end
end
end
if GENERIC_EVENTS[eventName] then
genericEventArg = select(GENERIC_EVENTS[eventName], ...)
assert(genericEventArg)
if not private.events[eventName][genericEventArg] then
return
end
private.EventHandlerHelper(private.events[eventName][genericEventArg], eventName, genericEventArg)
else
private.EventHandlerHelper(private.events[eventName], eventName, ...)
end
end
function private.ResponseReceivedHandler(eventName, ...)
Log.Info("%s (%s)", eventName, private.ArgsToStr(...))
private.lastResponseReceived = GetTime()
end
function private.UnusedEventHandler(eventName, ...)
Log.Info("%s (%s)", eventName, private.ArgsToStr(...))
end
function private.EventHandlerHelper(wrappers, eventName, ...)
if not SILENT_EVENTS[eventName] then
Log.Info("%s (%s)", eventName, private.ArgsToStr(...))
end
for _, wrapper in ipairs(wrappers) do
wrapper:_HandleEvent(eventName, ...)
end
end
function private.CheckAllIdle()
for apiName, wrapper in pairs(private.wrappers) do
if not wrapper:IsIdle() then
Log.Err("Another wrapper is pending (%s)", apiName)
return false
end
end
return true
end
function private.AuctionCanceledHandler(_, auctionId)
private.lastAuctionCanceledAuctionId = auctionId
private.lastAuctionCanceledTime = GetTime()
end
function private.ItemSearchResultsUpdated(_, itemKey, auctionId)
if private.lastAuctionCanceledTime == GetTime() and auctionId then
Log.Info("Auction ID changed from %s to %s", tostring(private.lastAuctionCanceledAuctionId), tostring(auctionId))
local newResultInfo = nil
for i = 1, C_AuctionHouse.GetNumItemSearchResults(itemKey) do
local info = C_AuctionHouse.GetItemSearchResultInfo(itemKey, i)
if info.auctionID == auctionId then
newResultInfo = info
break
end
end
if not newResultInfo then
Log.Warn("Failed to find new result info")
end
for _, callback in ipairs(private.auctionIdUpdateCallbacks) do
callback(private.lastAuctionCanceledAuctionId, auctionId, newResultInfo)
end
private.lastAuctionCanceledAuctionId = nil
private.lastAuctionCanceledTime = 0
end
end
function private.GetAnalyticsRegionRealm()
return TSM.GetRegion().."-"..gsub(GetRealmName(), "\226", "'")
end

View File

@@ -0,0 +1,65 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Auction scanning functions.
-- @module AuctionScan
local _, TSM = ...
local AuctionScan = TSM.Init("Service.AuctionScan")
local ScanManager = TSM.Include("Service.AuctionScanClasses.ScanManager")
-- ============================================================================
-- Module Functions
-- ============================================================================
function AuctionScan.GetManager()
return ScanManager.Get()
end
function AuctionScan.CanBid(subRow)
if not subRow:IsSubRow() then
return false
end
local _, _, _, isHighBidder = subRow:GetBidInfo()
local displayedBid = subRow:GetDisplayedBids()
local buyout = subRow:GetBuyouts()
if isHighBidder then
return false
elseif displayedBid == buyout then
return false
elseif not TSM.IsWowClassic() and subRow:IsCommodity() then
return false
elseif GetMoney() < subRow:GetRequiredBid() then
return false
end
return true
end
function AuctionScan.CanBuyout(subRow, auctionScan)
if not subRow:IsSubRow() then
return false
end
local buyout, itemBuyout = subRow:GetBuyouts()
if not TSM.IsWowClassic() then
buyout = itemBuyout
end
local itemString = subRow:GetItemString()
if buyout == 0 or GetMoney() < buyout then
return false
elseif not TSM.IsWowClassic() and subRow:IsCommodity() then
-- make sure it's the cheapest
local isCheapest = false
for _, query in auctionScan:QueryIterator() do
isCheapest = isCheapest or subRow == query:GetCheapestSubRow(itemString)
end
if not isCheapest then
return false
end
end
return true
end

View File

@@ -0,0 +1,675 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- AuctionQuery Class.
-- A class which is used to build a query to scan the auciton house.
-- @classmod AuctionQuery
local _, TSM = ...
local Query = TSM.Init("Service.AuctionScanClasses.Query")
local String = TSM.Include("Util.String")
local ObjectPool = TSM.Include("Util.ObjectPool")
local ItemString = TSM.Include("Util.ItemString")
local TempTable = TSM.Include("Util.TempTable")
local Table = TSM.Include("Util.Table")
local ItemInfo = TSM.Include("Service.ItemInfo")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local Scanner = TSM.Include("Service.AuctionScanClasses.Scanner")
local LibTSMClass = TSM.Include("LibTSMClass")
local AuctionQuery = LibTSMClass.DefineClass("AuctionQuery")
local private = {
objectPool = ObjectPool.New("AUCTION_SCAN_QUERY", AuctionQuery),
}
local ITEM_SPECIFIC = newproxy()
local ITEM_BASE = newproxy()
local DEFAULT_SORTS = TSM.IsWowClassic() and
{ -- classic
"seller",
"quantity",
"unitprice",
} or
{ -- retail
{ sortOrder = Enum.AuctionHouseSortOrder.Price, reverseSort = false },
{ sortOrder = Enum.AuctionHouseSortOrder.Name, reverseSort = false },
}
local EMPTY_SORTS = {}
local INV_TYPES = {
CHEST = TSM.IsShadowlands() and Enum.InventoryType.IndexChestType or LE_INVENTORY_TYPE_CHEST_TYPE,
ROBE = TSM.IsShadowlands() and Enum.InventoryType.IndexRobeType or LE_INVENTORY_TYPE_ROBE_TYPE,
NECK = TSM.IsShadowlands() and Enum.InventoryType.IndexNeckType or LE_INVENTORY_TYPE_NECK_TYPE,
FINGER = TSM.IsShadowlands() and Enum.InventoryType.IndexFingerType or LE_INVENTORY_TYPE_FINGER_TYPE,
TRINKET = TSM.IsShadowlands() and Enum.InventoryType.IndexTrinketType or LE_INVENTORY_TYPE_TRINKET_TYPE,
HOLDABLE = TSM.IsShadowlands() and Enum.InventoryType.IndexHoldableType or LE_INVENTORY_TYPE_HOLDABLE_TYPE,
BODY = TSM.IsShadowlands() and Enum.InventoryType.IndexBodyType or LE_INVENTORY_TYPE_BODY_TYPE,
CLOAK = TSM.IsShadowlands() and Enum.InventoryType.IndexCloakType or LE_INVENTORY_TYPE_CLOAK_TYPE,
}
assert(Table.Count(INV_TYPES) == 8)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Query.Get()
return private.objectPool:Get()
end
-- ============================================================================
-- Class Meta Methods
-- ============================================================================
function AuctionQuery.__init(self)
self._str = ""
self._strLower = ""
self._strMatch = ""
self._exact = false
self._minQuality = -math.huge
self._maxQuality = math.huge
self._minLevel = -math.huge
self._maxLevel = math.huge
self._minItemLevel = -math.huge
self._maxItemLevel = math.huge
self._class = nil
self._subClass = nil
self._invType = nil
self._classFilter1 = {}
self._classFilter2 = {}
self._usable = false
self._uncollected = false
self._upgrades = false
self._unlearned = false
self._canLearn = false
self._minPrice = 0
self._maxPrice = math.huge
self._items = {}
self._customFilters = {}
self._isBrowseDoneFunc = nil
self._specifiedPage = nil
self._getAll = nil
self._resolveSellers = false
self._callback = nil
self._queryTemp = {}
self._filtersTemp = {}
self._classFiltersTemp = {}
self._browseResults = {}
self._page = 0
end
function AuctionQuery.Release(self)
self._str = ""
self._strLower = ""
self._strMatch = ""
self._exact = false
self._minQuality = -math.huge
self._maxQuality = math.huge
self._minLevel = -math.huge
self._maxLevel = math.huge
self._minItemLevel = -math.huge
self._maxItemLevel = math.huge
self._class = nil
self._subClass = nil
self._invType = nil
wipe(self._classFilter1)
wipe(self._classFilter2)
self._usable = false
self._uncollected = false
self._upgrades = false
self._unlearned = false
self._canLearn = false
self._minPrice = 0
self._maxPrice = math.huge
wipe(self._items)
wipe(self._customFilters)
self._isBrowseDoneFunc = nil
self._specifiedPage = nil
self._getAll = nil
self._resolveSellers = false
self._callback = nil
wipe(self._queryTemp)
wipe(self._filtersTemp)
wipe(self._classFiltersTemp)
for _, row in pairs(self._browseResults) do
row:Release()
end
wipe(self._browseResults)
self._page = 0
private.objectPool:Recycle(self)
end
-- ============================================================================
-- Public Class Methods
-- ============================================================================
function AuctionQuery.SetStr(self, str, exact)
self._str = str or ""
self._strLower = strlower(self._str)
self._strMatch = String.Escape(self._strLower)
self._exact = exact or false
return self
end
function AuctionQuery.SetQualityRange(self, minQuality, maxQuality)
self._minQuality = minQuality or -math.huge
self._maxQuality = maxQuality or math.huge
return self
end
function AuctionQuery.SetLevelRange(self, minLevel, maxLevel)
self._minLevel = minLevel or -math.huge
self._maxLevel = maxLevel or math.huge
return self
end
function AuctionQuery.SetItemLevelRange(self, minItemLevel, maxItemLevel)
self._minItemLevel = minItemLevel or -math.huge
self._maxItemLevel = maxItemLevel or math.huge
return self
end
function AuctionQuery.SetClass(self, class, subClass, invType)
self._class = class or nil
self._subClass = subClass or nil
self._invType = invType or nil
return self
end
function AuctionQuery.SetUsable(self, usable)
self._usable = usable or false
return self
end
function AuctionQuery.SetUncollected(self, uncollected)
self._uncollected = uncollected or false
return self
end
function AuctionQuery.SetUpgrades(self, upgrades)
self._upgrades = upgrades or false
return self
end
function AuctionQuery.SetUnlearned(self, unlearned)
self._unlearned = unlearned or false
return self
end
function AuctionQuery.SetCanLearn(self, canLearn)
self._canLearn = canLearn or false
return self
end
function AuctionQuery.SetPriceRange(self, minPrice, maxPrice)
self._minPrice = minPrice or 0
self._maxPrice = maxPrice or math.huge
return self
end
function AuctionQuery.SetItems(self, items)
wipe(self._items)
if type(items) == "table" then
for _, itemString in ipairs(items) do
local baseItemString = ItemString.GetBaseFast(itemString)
self._items[itemString] = ITEM_SPECIFIC
if baseItemString ~= itemString then
self._items[baseItemString] = self._items[baseItemString] or ITEM_BASE
end
end
elseif type(items) == "string" then
local itemString = items
local baseItemString = ItemString.GetBaseFast(itemString)
self._items[itemString] = ITEM_SPECIFIC
if baseItemString ~= itemString then
self._items[baseItemString] = self._items[baseItemString] or ITEM_BASE
end
elseif items ~= nil then
error("Invalid items type: "..tostring(items))
end
return self
end
function AuctionQuery.AddCustomFilter(self, func)
self._customFilters[func] = true
return self
end
function AuctionQuery.SetIsBrowseDoneFunction(self, func)
self._isBrowseDoneFunc = func
return self
end
function AuctionQuery.SetPage(self, page)
if page == nil then
self._specifiedPage = nil
elseif type(page) == "number" or page == "FIRST" or page == "LAST" then
assert(TSM.IsWowClassic())
self._specifiedPage = page
else
error("Invalid page: "..tostring(page))
end
return self
end
function AuctionQuery.SetGetAll(self, getAll)
-- only currently support GetAll on classic
assert(not getAll or TSM.IsWowClassic())
self._getAll = getAll
return self
end
function AuctionQuery.SetResolveSellers(self, resolveSellers)
self._resolveSellers = resolveSellers
return self
end
function AuctionQuery.SetCallback(self, callback)
self._callback = callback
return self
end
function AuctionQuery.Browse(self, forceNoScan)
assert(not TSM.IsWowClassic() or not forceNoScan)
local noScan = forceNoScan or false
if not TSM.IsWowClassic() then
local numItems = 0
for _, itemType in pairs(self._items) do
if itemType == ITEM_SPECIFIC then
numItems = numItems + 1
end
end
if numItems > 0 and numItems < 500 then
-- it's faster to just issue individual item searches instead of a browse query
noScan = true
end
end
if noScan then
assert(not TSM.IsWowClassic())
local itemKeys = TempTable.Acquire()
for itemString in pairs(self._items) do
if itemString == ItemString.GetBaseFast(itemString) then
local itemId, battlePetSpeciesId = nil, nil
if ItemString.IsPet(itemString) then
itemId = ItemString.ToId(ItemString.GetPetCage())
battlePetSpeciesId = ItemString.ToId(itemString)
else
itemId = ItemString.ToId(itemString)
battlePetSpeciesId = 0
end
local itemKey = C_AuctionHouse.MakeItemKey(itemId, 0, 0, battlePetSpeciesId)
-- FIX for 9.0.1 bug where MakeItemKey randomly adds an itemLevel which breaks scanning
itemKey.itemLevel = 0
tinsert(itemKeys, itemKey)
end
end
local future = Scanner.BrowseNoScan(self, itemKeys, self._browseResults, self._callback)
TempTable.Release(itemKeys)
return future
else
self._page = 0
return Scanner.Browse(self, self._resolveSellers, self._browseResults, self._callback)
end
end
function AuctionQuery.GetSearchProgress(self)
if TSM.IsWowClassic() then
return 1
end
local progress, totalNum = 0, 0
for _, row in pairs(self._browseResults) do
progress = progress + row:_GetSearchProgress()
totalNum = totalNum + 1
end
if totalNum == 0 then
return 0
end
return progress / totalNum
end
function AuctionQuery.GetBrowseResults(self, baseItemString)
return self._browseResults[baseItemString]
end
function AuctionQuery.ItemSubRowIterator(self, itemString)
local result = TempTable.Acquire()
local baseItemString = ItemString.GetBaseFast(itemString)
local isBaseItemString = itemString == baseItemString
local row = self._browseResults[baseItemString]
if row then
for _, subRow in row:SubRowIterator() do
local subRowBaseItemString = subRow:GetBaseItemString()
local subRowItemString = subRow:GetItemString()
if (isBaseItemString and subRowBaseItemString == itemString) or (not isBaseItemString and subRowItemString == itemString) then
tinsert(result, subRow)
end
end
end
return TempTable.Iterator(result)
end
function AuctionQuery.GetCheapestSubRow(self, itemString)
assert(not TSM.IsWowClassic())
local cheapest, cheapestItemBuyout = nil, nil
for _, subRow in self:ItemSubRowIterator(itemString) do
local quantity = subRow:GetQuantities()
local _, numOwnerItems = subRow:GetOwnerInfo()
local _, itemBuyout = subRow:GetBuyouts()
if numOwnerItems ~= quantity and itemBuyout < (cheapestItemBuyout or math.huge) then
cheapest = subRow
cheapestItemBuyout = itemBuyout
end
end
return cheapest
end
function AuctionQuery.BrowseResultsIterator(self)
return pairs(self._browseResults)
end
function AuctionQuery.RemoveResultRow(self, row)
local baseItemString = row:GetBaseItemString()
assert(baseItemString and self._browseResults[baseItemString])
self._browseResults[baseItemString] = nil
row:Release()
if self._callback then
self._callback(self)
end
end
function AuctionQuery.Search(self, row, useCachedData)
assert(not TSM.IsWowClassic())
assert(self._browseResults)
return Scanner.Search(self, self._resolveSellers, useCachedData, row, self._callback)
end
function AuctionQuery.CancelBrowseOrSearch(self)
Scanner.Cancel()
end
function AuctionQuery.ItemIterator(self)
return private.ItemIteratorHelper, self._items, nil
end
function AuctionQuery.WipeBrowseResults(self)
for _, row in pairs(self._browseResults) do
row:Release()
end
wipe(self._browseResults)
if self._callback then
self._callback(self)
end
end
-- ============================================================================
-- Private Class Methods
-- ============================================================================
function AuctionQuery._SetSort(self)
if not TSM.IsWowClassic() then
return true
end
local sorts = (type(self._specifiedPage) == "string" or self._getAll) and EMPTY_SORTS or DEFAULT_SORTS
if GetAuctionSort("list", #sorts + 1) == nil then
local properlySorted = true
for i, col in ipairs(sorts) do
local sortCol, sortReversed = GetAuctionSort("list", #sorts - i + 1)
-- we never care to reverse a sort so if it's reversed then it's not properly sorted
if sortCol ~= col or sortReversed then
properlySorted = false
break
end
end
if properlySorted then
return true
end
end
SortAuctionClearSort("list")
for _, col in ipairs(sorts) do
SortAuctionSetSort("list", col, false)
end
SortAuctionApplySort("list")
return false
end
function AuctionQuery._SendWowQuery(self)
-- build the class filters
wipe(self._classFiltersTemp)
wipe(self._classFilter1)
wipe(self._classFilter2)
if self._invType == INV_TYPES.CHEST or self._invType == INV_TYPES.ROBE then
-- default AH only sends in queries for robe chest type, we need to mimic this when using a chest filter
self._classFilter1.classID = LE_ITEM_CLASS_ARMOR
self._classFilter1.subClassID = self._subClass
self._classFilter1.inventoryType = INV_TYPES.CHEST
tinsert(self._classFiltersTemp, self._classFilter1)
self._classFilter2.classID = LE_ITEM_CLASS_ARMOR
self._classFilter2.subClassID = self._subClass
self._classFilter2.inventoryType = INV_TYPES.ROBE
tinsert(self._classFiltersTemp, self._classFilter2)
elseif self._invType == INV_TYPES.NECK or self._invType == INV_TYPES.FINGER or self._invType == INV_TYPES.TRINKET or self._invType == INV_TYPES.HOLDABLE or self._invType == INV_TYPES.BODY then
self._classFilter1.classID = LE_ITEM_CLASS_ARMOR
self._classFilter1.subClassID = LE_ITEM_ARMOR_GENERIC
self._classFilter1.inventoryType = self._invType
tinsert(self._classFiltersTemp, self._classFilter1)
elseif self._invType == INV_TYPES.CLOAK then
self._classFilter1.classID = LE_ITEM_CLASS_ARMOR
self._classFilter1.subClassID = LE_ITEM_ARMOR_CLOTH
self._classFilter1.inventoryType = self._invType
tinsert(self._classFiltersTemp, self._classFilter1)
elseif self._class then
self._classFilter1.classID = self._class
self._classFilter1.subClassID = self._subClass
self._classFilter1.inventoryType = self._invType
tinsert(self._classFiltersTemp, self._classFilter1)
end
-- build the query
local minLevel = self._minLevel ~= -math.huge and self._minLevel or nil
local maxLevel = self._maxLevel ~= math.huge and self._maxLevel or nil
if TSM.IsWowClassic() then
if self._specifiedPage == "LAST" then
self._page = max(ceil(select(2, GetNumAuctionItems("list")) / NUM_AUCTION_ITEMS_PER_PAGE) - 1, 0)
elseif self._specifiedPage == "FIRST" then
self._page = 0
elseif self._specifiedPage then
self._page = self._specifiedPage
end
local minQuality = self._minQuality == -math.huge and 0 or self._minQuality
return AuctionHouseWrapper.QueryAuctionItems(self._str, minLevel, maxLevel, self._page, self._usable, minQuality, self._getAll, self._exact, self._classFiltersTemp)
else
wipe(self._filtersTemp)
if self._uncollected then
tinsert(self._filtersTemp, Enum.AuctionHouseFilter.UncollectedOnly)
end
if self._usable then
tinsert(self._filtersTemp, Enum.AuctionHouseFilter.UsableOnly)
end
if self._upgrades then
tinsert(self._filtersTemp, Enum.AuctionHouseFilter.UpgradesOnly)
end
if self._exact then
tinsert(self._filtersTemp, Enum.AuctionHouseFilter.ExactMatch)
end
local minQuality = self._minQuality == -math.huge and 0 or self._minQuality
for i = minQuality + Enum.AuctionHouseFilter.PoorQuality, min(self._maxQuality + Enum.AuctionHouseFilter.PoorQuality, Enum.AuctionHouseFilter.ArtifactQuality) do
tinsert(self._filtersTemp, i)
end
wipe(self._queryTemp)
self._queryTemp.searchString = self._str
self._queryTemp.minLevel = minLevel
self._queryTemp.maxLevel = maxLevel
self._queryTemp.sorts = DEFAULT_SORTS
self._queryTemp.filters = self._filtersTemp
self._queryTemp.itemClassFilters = self._classFiltersTemp
return AuctionHouseWrapper.SendBrowseQuery(self._queryTemp)
end
end
function AuctionQuery._IsFiltered(self, row, isSubRow, itemKey)
local baseItemString = row:GetBaseItemString()
local itemString = row:GetItemString()
assert(baseItemString)
local name, quality, itemLevel, maxItemLevel = row:GetItemInfo(itemKey)
local _, itemBuyout, minItemBuyout = row:GetBuyouts(itemKey)
if row:IsSubRow() and itemBuyout == 0 then
_, itemBuyout = row:GetBidInfo()
end
if next(self._items) then
if not self._items[baseItemString] then
return true
end
if isSubRow and itemString and self._items[itemString] ~= ITEM_SPECIFIC and self._items[baseItemString] ~= ITEM_SPECIFIC then
return true
elseif not isSubRow and itemString and not self._items[itemString] then
return true
end
end
if self._str ~= "" and name then
name = strlower(name)
if not strmatch(name, self._strMatch) or (self._exact and name ~= self._strLower) then
return true
end
end
if self._minLevel ~= -math.huge or self._maxLevel ~= math.huge then
local minLevel = TSM.IsShadowlands() and ItemString.IsPet(baseItemString) and (itemLevel or maxItemLevel) or ItemInfo.GetMinLevel(baseItemString)
if minLevel < self._minLevel or minLevel > self._maxLevel then
return true
end
end
if itemLevel and (itemLevel < self._minItemLevel or itemLevel > self._maxItemLevel) then
return true
end
if maxItemLevel and maxItemLevel < self._minItemLevel then
return true
end
if quality and (quality < self._minQuality or quality > self._maxQuality) then
return true
end
if self._class and ItemInfo.GetClassId(baseItemString) ~= self._class then
return true
end
if self._subClass and ItemInfo.GetSubClassId(baseItemString) ~= self._subClass then
return true
end
if self._invType and ItemInfo.GetInvSlotId(baseItemString) ~= self._invType then
return true
end
if self._unlearned and CanIMogIt:PlayerKnowsTransmog(ItemInfo.GetLink(baseItemString)) then
return true
end
if self._canLearn and not CanIMogIt:CharacterCanLearnTransmog(ItemInfo.GetLink(baseItemString)) then
return true
end
if itemBuyout and (itemBuyout < self._minPrice or itemBuyout > self._maxPrice) then
return true
end
if minItemBuyout and minItemBuyout > self._maxPrice then
return true
end
for func in pairs(self._customFilters) do
if func(self, row, isSubRow, itemKey) then
return true
end
end
return false
end
function AuctionQuery._BrowseIsDone(self, isRetry)
if TSM.IsWowClassic() then
local numAuctions, totalAuctions = GetNumAuctionItems("list")
if totalAuctions <= NUM_AUCTION_ITEMS_PER_PAGE and numAuctions ~= totalAuctions then
-- there are cases where we get (0, 1) from the API - no idea why so just assume we're not done
return false
end
local numPages = ceil(totalAuctions / NUM_AUCTION_ITEMS_PER_PAGE)
if self._getAll then
return true
end
if self._specifiedPage then
if isRetry then
return false
end
-- check if we're on the right page
local specifiedPage = (self._specifiedPage == "FIRST" and 0) or (self._specifiedPage == "LAST" and numPages - 1) or self._specifiedPage
return self._page == specifiedPage
elseif self._isBrowseDoneFunc and self._isBrowseDoneFunc(self) then
return true
else
return self._page >= numPages
end
else
if self._isBrowseDoneFunc and self._isBrowseDoneFunc(self) then
return true
end
return C_AuctionHouse.HasFullBrowseResults()
end
end
function AuctionQuery._BrowseIsPageValid(self)
if TSM.IsWowClassic() then
if self._specifiedPage then
return self:_BrowseIsDone()
else
return true
end
else
return true
end
end
function AuctionQuery._BrowseRequestMore(self, isRetry)
if TSM.IsWowClassic() then
assert(not self._getAll)
if self._specifiedPage then
return self:_SendWowQuery()
end
if not isRetry then
self._page = self._page + 1
end
return self:_SendWowQuery()
else
return AuctionHouseWrapper.RequestMoreBrowseResults()
end
end
function AuctionQuery._OnSubRowRemoved(self, row)
local baseItemString = row:GetBaseItemString()
assert(row == self._browseResults[baseItemString])
if row:GetNumSubRows() == 0 then
self._browseResults[baseItemString] = nil
row:Release()
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ItemIteratorHelper(items, index)
while true do
local itemString, itemType = next(items, index)
if not itemString then
return
elseif itemType == ITEM_SPECIFIC then
return itemString
end
index = itemString
end
end

View File

@@ -0,0 +1,134 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local QueryUtil = TSM.Init("Service.AuctionScanClasses.QueryUtil")
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local Threading = TSM.Include("Service.Threading")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Query = TSM.Include("Service.AuctionScanClasses.Query")
local private = {
itemListSortValue = {},
}
local MAX_ITEM_INFO_RETRIES = 30
-- ============================================================================
-- Module Functions
-- ============================================================================
function QueryUtil.GenerateThreaded(itemList, callback, context)
-- get all the item info into the game's cache
for _ = 1, MAX_ITEM_INFO_RETRIES do
local isMissingItemInfo = false
for _, itemString in ipairs(itemList) do
if not private.HasInfo(itemString) then
isMissingItemInfo = true
end
Threading.Yield()
end
if not isMissingItemInfo then
break
end
Threading.Sleep(0.1)
end
-- remove items we're missing info for
for i = #itemList, 1, -1 do
if not private.HasInfo(itemList[i]) then
Log.Err("Missing item info for %s", itemList[i])
tremove(itemList, i)
end
Threading.Yield()
end
if #itemList == 0 then
return
end
-- add all the items
if TSM.IsWowClassic() then
for _, itemString in ipairs(itemList) do
private.GenerateQuery(callback, context, itemString, private.GetItemQueryInfo(itemString))
end
else
-- sort the item list so all base items are grouped together but keep relative ordering between base items the same
wipe(private.itemListSortValue)
for i, itemString in ipairs(itemList) do
local baseItemString = ItemString.GetBaseFast(itemString)
private.itemListSortValue[baseItemString] = private.itemListSortValue[baseItemString] or i
private.itemListSortValue[itemString] = private.itemListSortValue[baseItemString]
end
sort(itemList, private.ItemListSortHelper)
local currentBaseItemString = nil
local currentItems = TempTable.Acquire()
for _, itemString in ipairs(itemList) do
local baseItemString = ItemString.GetBaseFast(itemString)
assert(baseItemString)
if baseItemString == currentBaseItemString then
-- same base item
tinsert(currentItems, itemString)
else
-- new base item
if currentBaseItemString then
private.GenerateQuery(callback, context, currentItems, ItemInfo.GetName(currentBaseItemString))
wipe(currentItems)
end
currentBaseItemString = baseItemString
tinsert(currentItems, itemString)
end
end
if currentBaseItemString then
private.GenerateQuery(callback, context, currentItems, ItemInfo.GetName(currentBaseItemString))
wipe(currentItems)
end
TempTable.Release(currentItems)
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetItemQueryInfo(itemString)
local name = ItemInfo.GetName(itemString)
local level = ItemInfo.GetMinLevel(itemString) or 0
local quality = ItemInfo.GetQuality(itemString)
local classId = ItemInfo.GetClassId(itemString) or 0
local subClassId = ItemInfo.GetSubClassId(itemString) or 0
-- Ignoring level because level can now vary
if itemString == ItemString.GetBase(itemString) and (classId == LE_ITEM_CLASS_WEAPON or classId == LE_ITEM_CLASS_ARMOR or (classId == LE_ITEM_CLASS_GEM and subClassId == LE_ITEM_GEM_ARTIFACTRELIC)) then
level = nil
end
return name, level, level, quality, classId, subClassId
end
function private.HasInfo(itemString)
return ItemInfo.GetName(itemString) and ItemInfo.GetQuality(itemString) and ItemInfo.GetMinLevel(itemString)
end
function private.GenerateQuery(callback, context, items, name, minLevel, maxLevel, quality, class, subClass)
local query = Query.Get()
:SetStr(name, false)
:SetQualityRange(quality, quality)
:SetLevelRange(minLevel, maxLevel)
:SetClass(class, subClass)
:SetItems(items)
callback(query, context)
end
function private.ItemListSortHelper(a, b)
local aSortValue = private.itemListSortValue[a]
local bSortValue = private.itemListSortValue[b]
if aSortValue ~= bSortValue then
return aSortValue < bSortValue
end
return a < b
end

View File

@@ -0,0 +1,671 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ResultRow = TSM.Init("Service.AuctionScanClasses.ResultRow")
local ItemString = TSM.Include("Util.ItemString")
local ObjectPool = TSM.Include("Util.ObjectPool")
local TempTable = TSM.Include("Util.TempTable")
local Table = TSM.Include("Util.Table")
local ItemInfo = TSM.Include("Service.ItemInfo")
local LibTSMClass = TSM.Include("LibTSMClass")
local Util = TSM.Include("Service.AuctionScanClasses.Util")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local ResultSubRow = TSM.Include("Service.AuctionScanClasses.ResultSubRow")
local ResultRowWrapper = LibTSMClass.DefineClass("ResultRowWrapper")
local private = {
objectPool = ObjectPool.New("AUCTION_SCAN_RESULT_ROW", ResultRowWrapper),
}
local SUB_ROW_SEARCH_INDEX_MULTIPLIER = 1000000
-- ============================================================================
-- Module Functions
-- ============================================================================
function ResultRow.Get(query, itemKey, minPrice, totalQuantity)
local row = private.objectPool:Get()
row:_Acquire(query, itemKey, minPrice, totalQuantity)
return row
end
-- ============================================================================
-- ResultRowWrapper - Meta Class Methods
-- ============================================================================
function ResultRowWrapper.__init(self)
self._query = nil
self._items = {}
self._baseItemString = nil
self._canHaveNonBaseItemString = nil
self._minPrice = nil
self._hasItemInfo = nil
self._isCommodity = nil
self._notFiltered = false
self._searchIndex = nil
self._subRows = {}
self._minBrowseId = nil
end
function ResultRowWrapper._Acquire(self, query, item, minPrice, totalQuantity)
self._query = query
if TSM.IsWowClassic() then
assert(not minPrice and not totalQuantity)
tinsert(self._items, item)
self._baseItemString = ItemString.GetBase(item)
else
item._minPrice = minPrice
item._totalQuantity = totalQuantity
tinsert(self._items, item)
self._baseItemString = ItemString.GetBaseFromItemKey(item)
end
self._canHaveNonBaseItemString = nil
self._minPrice = nil
end
-- ============================================================================
-- ResultRowWrapper - Public Class Methods
-- ============================================================================
function ResultRowWrapper.Merge(self, item, minPrice, totalQuantity)
-- check if we already have this item
for i = 1, #self._items do
if item == self._items[i] then
return
end
if type(item) == "table" then
local isEqual = true
for k in pairs(item) do
if item[k] ~= self._items[i][k] then
isEqual = false
break
end
end
if isEqual then
return
end
end
end
self._hasItemInfo = nil
if TSM.IsWowClassic() then
assert(not minPrice and not totalQuantity)
assert(self._baseItemString == ItemString.GetBase(item))
tinsert(self._items, item)
self._notFiltered = false
else
assert(self._baseItemString == ItemString.GetBaseFromItemKey(item))
item._minPrice = minPrice
item._totalQuantity = totalQuantity
tinsert(self._items, item)
self._notFiltered = false
end
self._canHaveNonBaseItemString = nil
end
function ResultRowWrapper.Release(self)
wipe(self._items)
self._baseItemString = nil
self._canHaveNonBaseItemString = nil
self._minPrice = nil
self._hasItemInfo = nil
self._isCommodity = nil
self._notFiltered = false
self._searchIndex = nil
self._minBrowseId = nil
for _, subRow in pairs(self._subRows) do
subRow:Release()
end
wipe(self._subRows)
private.objectPool:Recycle(self)
end
function ResultRowWrapper.IsSubRow(self)
return false
end
function ResultRowWrapper.PopulateBrowseData(self)
assert(self._baseItemString)
if self._hasItemInfo then
-- already have our item info
return true
elseif not Util.HasItemInfo(self._baseItemString) then
-- don't have item info yet
return false
end
if not TSM.IsWowClassic() then
-- cache the commodity status since it's referenced a ton
if self._isCommodity == nil then
self._isCommodity = ItemInfo.IsCommodity(self._baseItemString)
assert(self._isCommodity ~= nil)
end
end
-- check if we have info for all the items and try to fetch it if not
local missingInfo = false
for _, item in ipairs(self._items) do
if TSM.IsWowClassic() then
if not Util.HasItemInfo(ItemString.Get(item)) then
missingInfo = true
end
else
if not item._itemKeyInfo then
item._itemKeyInfo = C_AuctionHouse.GetItemKeyInfo(item, true)
if not item._itemKeyInfo then
missingInfo = true
end
end
end
end
if missingInfo then
return false
end
self._hasItemInfo = true
return true
end
function ResultRowWrapper.IsFiltered(self, query)
assert(#self._items > 0)
if self._notFiltered then
return false
end
-- check if the whole row is filtered
if query:_IsFiltered(self, false) then
return true
end
-- filter our items
for i = #self._items, 1, -1 do
if query:_IsFiltered(self, false, self._items[i]) then
tremove(self._items, i)
end
end
self._canHaveNonBaseItemString = nil
self._minPrice = nil
if #self._items == 0 then
-- no more items, so the entire row is filtered
return true
end
-- not filtered (cache this result)
self._notFiltered = true
return false
end
function ResultRowWrapper.SearchReset(self)
assert(not TSM.IsWowClassic())
assert(#self._items > 0)
self._searchIndex = 1
end
function ResultRowWrapper.SearchNext(self)
assert(not TSM.IsWowClassic())
assert(self._searchIndex)
if self._searchIndex == #self._items then
self._searchIndex = nil
return false
end
self._searchIndex = self._searchIndex + 1
return true
end
function ResultRowWrapper.SearchIsReady(self)
assert(not TSM.IsWowClassic())
assert(self._searchIndex)
-- the client needs to have the item key info cached before we can run the search
return C_AuctionHouse.GetItemKeyInfo(self._items[self._searchIndex], true) and true or false
end
function ResultRowWrapper.SearchSend(self)
assert(not TSM.IsWowClassic())
assert(self._searchIndex)
local itemKey = self._items[self._searchIndex]
-- send a sell query if we don't have browse results for the itemKey
-- for some reason sell queries don't work for commodities or pets
local isSellQuery = not self._isCommodity and not ItemString.IsPet(self._baseItemString) and not itemKey._totalQuantity
return AuctionHouseWrapper.SendSearchQuery(itemKey, isSellQuery)
end
function ResultRowWrapper.HasCachedSearchData(self)
local itemKey = self._items[self._searchIndex]
if self._isCommodity then
return C_AuctionHouse.HasFullCommoditySearchResults(itemKey.itemID)
else
return C_AuctionHouse.HasFullItemSearchResults(itemKey)
end
end
function ResultRowWrapper.SearchCheckStatus(self)
assert(not TSM.IsWowClassic())
assert(self._searchIndex)
local itemKey = self._items[self._searchIndex]
-- check if we have the full results
local hasFullResults = nil
if self._isCommodity then
hasFullResults = C_AuctionHouse.HasFullCommoditySearchResults(itemKey.itemID)
else
hasFullResults = C_AuctionHouse.HasFullItemSearchResults(itemKey)
end
if hasFullResults then
return true
end
-- request more results
if self._isCommodity then
return false, AuctionHouseWrapper.RequestMoreCommoditySearchResults(itemKey.itemID)
else
return false, AuctionHouseWrapper.RequestMoreItemSearchResults(itemKey)
end
end
function ResultRowWrapper.PopulateSubRows(self, browseId, index, itemLink)
if TSM.IsWowClassic() then
-- remove any prior results with a different browseId
assert(index and not self._searchIndex)
local subRow = ResultSubRow.Get(self)
subRow:_SetRawData(index, browseId, itemLink)
local _, hashNoSeller = subRow:GetHashes()
if self._minBrowseId and self._minBrowseId ~= browseId then
-- check if this subRow already exists with a prior browseId
for i, existingSubRow in ipairs(self._subRows) do
local _, existingHashNoSeller = existingSubRow:GetHashes()
local _, _, existingBrowseId = existingSubRow:GetListingInfo()
if hashNoSeller == existingHashNoSeller and browseId ~= existingBrowseId then
-- replace the existing subRow
existingSubRow:Release()
self._subRows[i] = subRow
return
end
end
end
tinsert(self._subRows, subRow)
else
assert(self._searchIndex and not index)
local subRowOffset = self._searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER
local itemKey = self._items[self._searchIndex]
local numAuctions = nil
if self:IsCommodity() then
numAuctions = C_AuctionHouse.GetNumCommoditySearchResults(itemKey.itemID)
else
numAuctions = C_AuctionHouse.GetNumItemSearchResults(itemKey)
end
if itemKey._numAuctions and numAuctions ~= itemKey._numAuctions then
-- the results changed so clear out our existing data
for i = itemKey._numAuctions, 1, -1 do
if i > numAuctions then
self._subRows[subRowOffset + i]:Release()
self._subRows[subRowOffset + i] = nil
else
self._subRows[subRowOffset + i]:_SetRawData(nil)
end
end
end
itemKey._numAuctions = numAuctions
for i = 1, numAuctions do
self._subRows[subRowOffset + i] = self._subRows[subRowOffset + i] or ResultSubRow.Get(self)
local subRow = self._subRows[subRowOffset + i]
if not subRow:HasRawData() or not subRow:HasOwners() then
local result = nil
if self:IsCommodity() then
result = C_AuctionHouse.GetCommoditySearchResultInfo(itemKey.itemID, i)
else
result = C_AuctionHouse.GetItemSearchResultInfo(itemKey, i)
end
subRow:_SetRawData(result, browseId)
end
end
end
self._minBrowseId = min(self._minBrowseId or math.huge, browseId)
end
function ResultRowWrapper.FilterSubRows(self, query)
local subRowOffset = TSM.IsWowClassic() and 0 or (self._searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER)
if TSM.IsWowClassic() then
for i = #self._subRows, 1, -1 do
if query:_IsFiltered(self._subRows[i], true) then
self:_RemoveSubRowByIndex(i)
end
end
else
local itemKey = self._items[self._searchIndex]
for j = itemKey._numAuctions, 1, -1 do
local subRow = self._subRows[subRowOffset + j]
if query:_IsFiltered(subRow, true) then
self:_RemoveSubRowByIndex(j)
end
end
end
-- merge subRows with identical hashes
local numSubRows = nil
local hashIndexLookup = TempTable.Acquire()
local index = 1
while true do
numSubRows = TSM.IsWowClassic() and #self._subRows or self._items[self._searchIndex]._numAuctions
if index > numSubRows then
break
end
local subRow = self._subRows[subRowOffset + index]
local hash = subRow:GetHashes()
local prevIndex = hashIndexLookup[hash]
if prevIndex then
-- there was a previous subRow with the same hash
self._subRows[subRowOffset + prevIndex]:Merge(subRow)
-- remove this subRow
self:_RemoveSubRowByIndex(index)
else
hashIndexLookup[hash] = index
index = index + 1
end
end
TempTable.Release(hashIndexLookup)
return numSubRows == 0
end
function ResultRowWrapper.GetNumSubRows(self)
if TSM.IsWowClassic() then
return #self._subRows
else
local result = 0
for _, itemKey in ipairs(self._items) do
result = result + (itemKey._numAuctions or 0)
end
return result
end
end
function ResultRowWrapper.SubRowIterator(self, searchOnly)
if TSM.IsWowClassic() then
return ipairs(self._subRows)
else
if searchOnly then
local result = TempTable.Acquire()
assert(self._searchIndex)
for i = 1, self._items[self._searchIndex]._numAuctions do
local subRow = self._subRows[self._searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER + i]
assert(subRow)
tinsert(result, subRow)
end
return TempTable.Iterator(result)
else
return private.SubRowIteratorHelper, self, SUB_ROW_SEARCH_INDEX_MULTIPLIER
end
end
end
function ResultRowWrapper.IsCommodity(self)
assert(self._isCommodity ~= nil)
return self._isCommodity
end
function ResultRowWrapper.HasItemInfo(self)
return self._hasItemInfo
end
function ResultRowWrapper.GetBaseItemString(self)
return self._baseItemString
end
function ResultRowWrapper.GetItemString(self)
if TSM.IsWowClassic() or not self._hasItemInfo or self._canHaveNonBaseItemString then
return nil
end
if self._canHaveNonBaseItemString == nil then
for _, itemKey in ipairs(self._items) do
if ItemInfo.CanHaveVariations(self._baseItemString) or itemKey.battlePetSpeciesID ~= 0 or itemKey.itemSuffix ~= 0 or itemKey.itemLevel ~= 0 then
-- this item can have variations, so we don't know its itemString
self._canHaveNonBaseItemString = true
return nil
end
end
self._canHaveNonBaseItemString = false
end
return self._baseItemString
end
function ResultRowWrapper.GetItemInfo(self, itemKey)
if TSM.IsWowClassic() or not self._hasItemInfo then
return nil, nil, nil, nil
end
itemKey = itemKey or (#self._items == 1 and self._items[1] or nil)
assert(not itemKey or itemKey._itemKeyInfo)
local baseItemString = self:GetBaseItemString()
local itemString = self:GetItemString()
local itemName, quality, itemLevel, maxItemLevel = nil, nil, nil, nil
if itemString then
-- this item can't have variations, so we can know the name / level / quality
itemName = ItemInfo.GetName(baseItemString)
itemLevel = ItemInfo.GetItemLevel(baseItemString)
quality = ItemInfo.GetQuality(baseItemString)
assert(itemName and itemLevel and quality)
else
if itemKey and not itemKey._totalQuantity then
-- if we didn't do a browse, then don't use this itemKey
itemKey = nil
end
if itemKey then
-- grab the name from the itemKeyInfo
itemName = itemKey._itemKeyInfo.itemName
assert(itemName)
end
local hasSingleAuction = itemKey and itemKey._totalQuantity == 1
if hasSingleAuction then
-- grab the quality from the itemKeyInfo since there's only one listing
quality = itemKey._itemKeyInfo.quality
assert(quality)
end
if not ItemString.IsPet(self._baseItemString) then
-- for non-pets, we can maybe grab the itemLevel from the itemKey
if itemKey then
itemLevel = itemKey.itemLevel ~= 0 and itemKey.itemLevel or nil
else
-- only use the itemLevel from the itemKeys if they are all the same
local itemKeyItemLevel = self._items[1].itemLevel
for i = 2, #self._items do
if self._items[i].itemLevel ~= itemKeyItemLevel then
itemKeyItemLevel = nil
break
end
end
itemLevel = (itemKeyItemLevel or 0) ~= 0 and itemKeyItemLevel or nil
end
elseif itemKey and itemKey._itemKeyInfo.battlePetLink then
if hasSingleAuction then
-- grab the itemLevel from the link since there's only one listing
itemLevel = ItemInfo.GetItemLevel(itemKey._itemKeyInfo.battlePetLink)
assert(itemLevel)
else
-- grab the maxItemLevel from the link
maxItemLevel = ItemInfo.GetItemLevel(itemKey._itemKeyInfo.battlePetLink)
assert(maxItemLevel)
end
end
end
return itemName, quality, itemLevel, maxItemLevel
end
function ResultRowWrapper.GetBuyouts(self, resultItemKey)
if TSM.IsWowClassic() then
return nil, nil, nil
end
assert(#self._items > 0)
if resultItemKey then
return nil, nil, resultItemKey._minPrice
else
if self._minPrice == nil then
for _, itemKey in ipairs(self._items) do
if not itemKey._minPrice then
self._minPrice = -1
return nil, nil, nil
end
self._minPrice = min(self._minPrice or math.huge, itemKey._minPrice)
end
elseif self._minPrice == -1 then
return nil, nil, nil
end
return nil, nil, self._minPrice
end
end
function ResultRowWrapper.GetQuantities(self)
local totalQuantity = 0
if TSM.IsWowClassic() then
for _, subRow in ipairs(self._subRows) do
local quantity, numAuctions = subRow:GetQuantities()
totalQuantity = totalQuantity + quantity * numAuctions
end
else
for _, itemKey in ipairs(self._items) do
if not itemKey._totalQuantity then
return
end
totalQuantity = totalQuantity + itemKey._totalQuantity
end
end
return totalQuantity, 1
end
function ResultRowWrapper.GetMaxQuantities(self)
assert(self:IsCommodity())
local totalQuantity = 0
for _, subRow in self:SubRowIterator() do
local _, numOwnerItems = subRow:GetOwnerInfo()
local quantityAvailable = subRow:GetQuantities() - numOwnerItems
totalQuantity = totalQuantity + quantityAvailable
end
return totalQuantity
end
function ResultRowWrapper.RemoveSubRow(self, subRow)
local index = Table.KeyByValue(self._subRows, subRow)
if TSM.IsWowClassic() then
self:_RemoveSubRowByIndex(index)
else
local searchIndex = floor(index / SUB_ROW_SEARCH_INDEX_MULTIPLIER)
index = index % SUB_ROW_SEARCH_INDEX_MULTIPLIER
assert(self._subRows[searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER + index] == subRow)
local prevSearchIndex = self._searchIndex
self._searchIndex = searchIndex
self:_RemoveSubRowByIndex(index)
self._searchIndex = prevSearchIndex
end
self._query:_OnSubRowRemoved(self)
end
function ResultRowWrapper.WipeSearchResults(self)
wipe(self._subRows)
if not TSM.IsWowClassic() then
for _, itemKey in ipairs(self._items) do
itemKey._numAuctions = nil
end
end
end
function ResultRowWrapper.GetQuery(self)
return self._query
end
function ResultRowWrapper.DecrementQuantity(self, amount)
assert(self:IsCommodity() and not TSM.IsWowClassic() and #self._items == 1)
local index = 1
while amount > 0 do
local subRow = self._subRows[index + SUB_ROW_SEARCH_INDEX_MULTIPLIER]
assert(subRow)
local _, numOwnerItems = subRow:GetOwnerInfo()
local quantityAvailable = subRow:GetQuantities() - numOwnerItems
if quantityAvailable > 0 then
local usedQuantity = min(quantityAvailable, amount)
local prevItemBuyout = floor(subRow._buyout / subRow._quantity)
amount = amount - usedQuantity
subRow._quantity = subRow._quantity - usedQuantity
subRow._buyout = prevItemBuyout * subRow._quantity
subRow._minBid = subRow._buyout
if numOwnerItems == 0 and subRow._quantity == 0 then
self:RemoveSubRow(subRow)
else
index = index + 1
end
else
index = index + 1
end
end
end
function ResultRowWrapper.GetMinBrowseId(self)
return self._minBrowseId
end
-- ============================================================================
-- ResultRowWrapper - Private Class Methods
-- ============================================================================
function ResultRowWrapper._RemoveSubRowByIndex(self, index)
if TSM.IsWowClassic() then
self._subRows[index]:Release()
tremove(self._subRows, index)
else
local subRowOffset = self._searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER
local itemKey = self._items[self._searchIndex]
self._subRows[subRowOffset + index]:Release()
self._subRows[subRowOffset + index] = nil
-- shift the other subRows for this item down
for i = index, itemKey._numAuctions - 1 do
self._subRows[subRowOffset + i] = self._subRows[subRowOffset + i + 1]
end
self._subRows[subRowOffset + itemKey._numAuctions] = nil
itemKey._numAuctions = itemKey._numAuctions - 1
end
end
function ResultRowWrapper._GetSearchProgress(self)
assert(not TSM.IsWowClassic())
if #self._items == 0 then
return 0
end
local numSearched = 0
for _, itemKey in ipairs(self._items) do
if itemKey._numAuctions then
numSearched = numSearched + 1
end
end
return numSearched / #self._items
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.SubRowIteratorHelper(row, index)
local searchIndex = floor(index / SUB_ROW_SEARCH_INDEX_MULTIPLIER)
local subRowIndex = index % SUB_ROW_SEARCH_INDEX_MULTIPLIER
while true do
local itemKey = row._items[searchIndex]
if not itemKey then
return
end
if subRowIndex >= (itemKey._numAuctions or 0) then
searchIndex = searchIndex + 1
subRowIndex = 0
else
subRowIndex = subRowIndex + 1
index = searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER + subRowIndex
return index, row._subRows[index]
end
end
end

View File

@@ -0,0 +1,364 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ResultSubRow = TSM.Init("Service.AuctionScanClasses.ResultSubRow")
local ItemString = TSM.Include("Util.ItemString")
local ObjectPool = TSM.Include("Util.ObjectPool")
local Math = TSM.Include("Util.Math")
local ItemInfo = TSM.Include("Service.ItemInfo")
local LibTSMClass = TSM.Include("LibTSMClass")
local Util = TSM.Include("Service.AuctionScanClasses.Util")
local ResultSubRowWrapper = LibTSMClass.DefineClass("ResultSubRowWrapper")
local private = {
objectPool = ObjectPool.New("AUCTION_SCAN_RESULT_SUB_ROW", ResultSubRowWrapper),
ownersTemp = {},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function ResultSubRow.Get(resultRow)
local subRow = private.objectPool:Get()
subRow:_Acquire(resultRow)
return subRow
end
-- ============================================================================
-- ResultSubRowWrapper - Meta Class Methods
-- ============================================================================
function ResultSubRowWrapper.__init(self)
self._resultRow = nil
self._itemLink = nil
self._buyout = nil
self._minBid = nil
self._currentBid = nil
self._minIncrement = nil
self._isHighBidder = nil
self._quantity = nil
self._timeLeft = nil
self._ownerStr = nil
self._hasOwners = false
self._numOwnerItems = nil
self._auctionId = nil
self._hash = nil
self._hashNoSeller = nil
self._browseId = nil
self._numAuctions = 1
end
function ResultSubRowWrapper._Acquire(self, resultRow)
self._resultRow = resultRow
end
-- ============================================================================
-- ResultSubRowWrapper - Public Class Methods
-- ============================================================================
function ResultSubRowWrapper.Merge(self, other)
if TSM.IsWowClassic() then
self._numAuctions = self._numAuctions + other._numAuctions
else
if self:IsCommodity() then
self._quantity = self._quantity + other._quantity
self._numOwnerItems = self._numOwnerItems + other._numOwnerItems
else
self._numAuctions = self._numAuctions + other._numAuctions
end
end
end
function ResultSubRowWrapper.Release(self)
self._resultRow = nil
self._numAuctions = 1
self:_SetRawData(nil)
private.objectPool:Recycle(self)
end
function ResultSubRowWrapper.IsSubRow(self)
return true
end
function ResultSubRowWrapper.HasRawData(self)
return self._timeLeft and true or false
end
function ResultSubRowWrapper.HasOwners(self)
return self._hasOwners
end
function ResultSubRowWrapper.HasItemString(self)
assert(self:HasRawData())
local itemString = ItemString.Get(self._itemLink)
if not Util.HasItemInfo(itemString) then
return false
end
return true
end
function ResultSubRowWrapper.IsCommodity(self)
return self._resultRow:IsCommodity()
end
function ResultSubRowWrapper.GetResultRow(self)
return self._resultRow
end
function ResultSubRowWrapper.GetBaseItemString(self)
return self._resultRow:GetBaseItemString()
end
function ResultSubRowWrapper.GetItemString(self)
assert(self:HasRawData())
local itemString = ItemString.Get(self._itemLink)
return itemString or self._resultRow:GetItemString()
end
function ResultSubRowWrapper.GetItemInfo(self)
assert(self:HasItemString())
local itemString = ItemString.Get(self._itemLink)
local itemName = ItemInfo.GetName(itemString)
local quality = ItemInfo.GetQuality(itemString)
local itemLevel = ItemInfo.GetItemLevel(itemString)
assert(itemName and quality and itemLevel)
return itemName, quality, itemLevel, nil
end
function ResultSubRowWrapper.GetBuyouts(self)
assert(self:HasRawData())
return self._buyout, floor(self._buyout / self._quantity), nil
end
function ResultSubRowWrapper.GetBidInfo(self)
assert(self:HasRawData())
local itemMinBid = Math.Floor(self._minBid / self._quantity, TSM.IsWowClassic() and 1 or COPPER_PER_SILVER)
return self._minBid, itemMinBid, self._currentBid, self._isHighBidder, self._minIncrement
end
function ResultSubRowWrapper.GetRequiredBid(self)
local requiredBid = nil
if TSM.IsWowClassic() then
requiredBid = self._currentBid == 0 and self._minBid or (self._currentBid + self._minIncrement)
else
requiredBid = self._minBid
end
return requiredBid
end
function ResultSubRowWrapper.GetDisplayedBids(self)
local displayedBid = self._currentBid == 0 and self._minBid or self._currentBid
local itemDisplayedBid = Math.Floor(displayedBid / self._quantity, TSM.IsWowClassic() and 1 or COPPER_PER_SILVER)
return displayedBid, itemDisplayedBid
end
function ResultSubRowWrapper.GetLinks(self)
assert(self:HasRawData())
local rawLink = self._itemLink
local itemLink = ItemInfo.GeneralizeLink(rawLink)
return itemLink, rawLink
end
function ResultSubRowWrapper.GetListingInfo(self)
assert(self:HasRawData())
return self._timeLeft, self._auctionId, self._browseId
end
function ResultSubRowWrapper.GetQuantities(self)
assert(self:HasRawData())
return self._quantity, self._numAuctions
end
function ResultSubRowWrapper.GetOwnerInfo(self)
assert(self:HasRawData())
return self._ownerStr, self._numOwnerItems
end
function ResultSubRowWrapper.GetHashes(self)
if not self._hash then
assert(self:HasRawData())
if TSM.IsWowClassic() then
self._hash = strjoin("~", tostringall(self._itemLink, self._minBid, self._minIncrement, self._buyout, self._currentBid, self._ownerStr, self._timeLeft, self._quantity, self._isHighBidder))
self._hashNoSeller = strjoin("~", tostringall(self._itemLink, self._minBid, self._minIncrement, self._buyout, self._currentBid, self._timeLeft, self._quantity, self._isHighBidder))
else
local baseItemString = self:GetBaseItemString()
local itemMinBid = Math.Floor(self._minBid / self._quantity, COPPER_PER_SILVER)
local itemBuyout = floor(self._buyout / self._quantity)
local itemKeyId, itemKeySpeciesId = nil, nil
if ItemString.IsPet(baseItemString) then
itemKeyId = ItemString.ToId(ItemString.GetPetCage())
itemKeySpeciesId = ItemString.ToId(baseItemString)
elseif ItemString.IsItem(baseItemString) then
itemKeyId = ItemString.ToId(baseItemString)
itemKeySpeciesId = 0
else
error("Invalid baseItemString: "..tostring(baseItemString))
end
if self:IsCommodity() then
self._hash = strjoin("~", tostringall(itemKeyId, itemBuyout, self._auctionId, self._ownerStr))
self._hashNoSeller = strjoin("~", tostringall(itemKeyId, itemBuyout, self._auctionId))
else
self._hash = strjoin("~", tostringall(itemKeyId, itemKeySpeciesId, self._itemLink, itemMinBid, itemBuyout, self._currentBid, self._quantity, self._isHighBidder, self._ownerStr, self._auctionId))
self._hashNoSeller = strjoin("~", tostringall(itemKeyId, itemKeySpeciesId, self._itemLink, itemMinBid, itemBuyout, self._currentBid, self._quantity, self._isHighBidder, self._auctionId))
end
end
end
return self._hash, self._hashNoSeller
end
function ResultSubRowWrapper.EqualsIndex(self, index, noSeller)
assert(TSM.IsWowClassic())
local _, _, stackSize, _, _, _, _, minBid, minIncrement, buyout, bid, isHighBidder, _, seller, sellerFull = GetAuctionItemInfo("list", index)
seller = Util.FixSellerName(seller, sellerFull) or "?"
-- this is to get around a bug in Blizzard's code where the minIncrement value will be inconsistent for auctions where the player is the highest bidder
minIncrement = isHighBidder and 0 or minIncrement
if minBid ~= self._minBid or minIncrement ~= self._minIncrement or buyout ~= self._buyout or bid ~= self._currentBid or stackSize == self._quantity and isHighBidder ~= self._isHighBidder then
return false
elseif not noSeller and seller ~= self._ownerStr then
return false
elseif GetAuctionItemLink("list", index) ~= self._itemLink then
return false
elseif GetAuctionItemTimeLeft("list", index) ~= self._timeLeft then
return false
end
return true
end
function ResultSubRowWrapper.DecrementQuantity(self, amount)
if TSM.IsWowClassic() then
assert(amount == self._quantity)
self._numAuctions = self._numAuctions - 1
if self._numAuctions == 0 then
self._resultRow:RemoveSubRow(self)
end
else
if self:IsCommodity() then
self._resultRow:DecrementQuantity(amount)
else
assert(amount == 1 and amount == self._quantity)
self._numAuctions = self._numAuctions - 1
assert(self._numOwnerItems <= self._numAuctions)
if self._numAuctions == 0 then
self._resultRow:RemoveSubRow(self)
end
end
end
end
function ResultSubRowWrapper.UpdateResultInfo(self, newAuctionId, newResultInfo)
if newResultInfo then
self:_SetRawData(newResultInfo, self._browseId)
else
self._auctionId = newAuctionId
self._hash = nil
self._hashNoSeller = nil
end
end
-- ============================================================================
-- ResultRowWrapper - Private Class Methods
-- ============================================================================
function ResultSubRowWrapper._SetRawData(self, data, browseId, itemLink)
self._hash = nil
self._hashNoSeller = nil
self._browseId = browseId
if data then
if TSM.IsWowClassic() then
local _, _, stackSize, _, _, _, _, minBid, minIncrement, buyout, bid, isHighBidder, _, seller, sellerFull = GetAuctionItemInfo("list", data)
seller = Util.FixSellerName(seller, sellerFull)
-- this is to get around a bug in Blizzard's code where the minIncrement value will be inconsistent for auctions where the player is the highest bidder
minIncrement = isHighBidder and 0 or minIncrement
self._itemLink = itemLink
self._buyout = buyout
self._minBid = minBid
self._currentBid = bid
self._minIncrement = minIncrement
self._isHighBidder = isHighBidder
self._quantity = stackSize
self._timeLeft = GetAuctionItemTimeLeft("list", data)
self._ownerStr = seller or "?"
self._hasOwners = seller and true or false
self._numOwnerItems = 0
self._auctionId = 0
else
if self._resultRow:IsCommodity() then
local baseItemString = self._resultRow:GetBaseItemString()
self._itemLink = ItemInfo.GetLink(baseItemString)
else
self._itemLink = data.itemLink
end
if self:IsCommodity() then
self._quantity = data.quantity
self._buyout = data.unitPrice * data.quantity
self._minBid = self._buyout
self._currentBid = 0
self._minIncrement = 0
self._isHighBidder = data.bidder and data.bidder == UnitGUID("player") or false
self._numOwnerItems = data.numOwnerItems or 0
-- convert the timeLeftSeconds to regular timeLeft
if data.timeLeftSeconds < 60 * 60 then
self._timeLeft = 1
elseif data.timeLeftSeconds < 2 * 60 * 60 then
self._timeLeft = 2
elseif data.timeLeftSeconds < 12 * 60 * 60 then
self._timeLeft = 3
else
self._timeLeft = 4
end
else
self._quantity = 1
self._numAuctions = data.quantity
self._buyout = data.buyoutAmount or 0
self._minBid = data.minBid or data.buyoutAmount
self._currentBid = data.bidAmount or 0
self._minIncrement = 0
self._isHighBidder = false
self._numOwnerItems = data.containsAccountItem and data.quantity or 0
self._timeLeft = data.timeLeft + 1
end
self._hasOwners = #data.owners > 0
assert(#private.ownersTemp == 0)
for _, owner in ipairs(data.owners) do
if owner == "player" then
owner = UnitName("player")
elseif owner == "" then
owner = "?"
self._hasOwners = false
end
tinsert(private.ownersTemp, owner)
end
self._ownerStr = table.concat(private.ownersTemp, ",")
wipe(private.ownersTemp)
self._auctionId = data.auctionID
end
assert(self._itemLink and self._quantity and self._buyout and self._minBid and self._currentBid and self._numOwnerItems and self._timeLeft and self._ownerStr and self._auctionId)
else
self._itemLink = nil
self._buyout = nil
self._minBid = nil
self._currentBid = nil
self._minIncrement = nil
self._isHighBidder = nil
self._quantity = nil
self._timeLeft = nil
self._ownerStr = nil
self._hasOwners = false
self._numOwnerItems = nil
self._auctionId = nil
end
end

View File

@@ -0,0 +1,620 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
-- This file contains code for scanning the auction house
local _, TSM = ...
local ScanManager = TSM.Init("Service.AuctionScanClasses.ScanManager")
local L = TSM.Include("Locale").GetTable()
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local Math = TSM.Include("Util.Math")
local ObjectPool = TSM.Include("Util.ObjectPool")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local Threading = TSM.Include("Service.Threading")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Query = TSM.Include("Service.AuctionScanClasses.Query")
local QueryUtil = TSM.Include("Service.AuctionScanClasses.QueryUtil")
local AuctionScanManager = TSM.Include("LibTSMClass").DefineClass("AuctionScanManager")
local private = {
objectPool = ObjectPool.New("AUCTION_SCAN_MANAGER", AuctionScanManager),
}
-- arbitrary estimate that finishing the browse request is worth 10% of the query's progress
local BROWSE_PROGRESS = 0.1
-- ============================================================================
-- Module Functions
-- ============================================================================
function ScanManager.Get()
return private.objectPool:Get()
end
-- ============================================================================
-- Class Meta Methods
-- ============================================================================
function AuctionScanManager.__init(self)
self._resolveSellers = nil
self._ignoreItemLevel = nil
self._queries = {}
self._queriesScanned = 0
self._queryDidBrowse = false
self._onProgressUpdateHandler = nil
self._onQueryDoneHandler = nil
self._resultsUpdateCallbacks = {}
self._nextSearchItemFunction = nil
self._currentSearchChangedCallback = nil
self._findResult = {}
self._cancelled = false
self._shouldPause = false
self._paused = false
self._scanQuery = nil
self._findQuery = nil
self._numItems = nil
self._queryCallback = function(query, searchRow)
for func in pairs(self._resultsUpdateCallbacks) do
func(self, query, searchRow)
end
end
end
function AuctionScanManager._Release(self)
self._resolveSellers = nil
self._ignoreItemLevel = nil
for _, query in ipairs(self._queries) do
query:Release()
end
wipe(self._queries)
self._queriesScanned = 0
self._queryDidBrowse = false
self._onProgressUpdateHandler = nil
self._onQueryDoneHandler = nil
wipe(self._resultsUpdateCallbacks)
self._nextSearchItemFunction = nil
self._currentSearchChangedCallback = nil
self._cancelled = false
self._shouldPause = false
self._paused = false
wipe(self._findResult)
self._scanQuery = nil
if self._findQuery then
self._findQuery:Release()
self._findQuery = nil
end
self._numItems = nil
end
-- ============================================================================
-- Public Class Methods
-- ============================================================================
function AuctionScanManager.Release(self)
self:_Release()
private.objectPool:Recycle(self)
end
function AuctionScanManager.SetResolveSellers(self, resolveSellers)
self._resolveSellers = resolveSellers
return self
end
function AuctionScanManager.SetIgnoreItemLevel(self, ignoreItemLevel)
self._ignoreItemLevel = ignoreItemLevel
return self
end
function AuctionScanManager.SetScript(self, script, handler)
if script == "OnProgressUpdate" then
self._onProgressUpdateHandler = handler
elseif script == "OnQueryDone" then
self._onQueryDoneHandler = handler
elseif script == "OnCurrentSearchChanged" then
self._currentSearchChangedCallback = handler
else
error("Unknown AuctionScanManager script: "..tostring(script))
end
return self
end
function AuctionScanManager.AddResultsUpdateCallback(self, func)
self._resultsUpdateCallbacks[func] = true
end
function AuctionScanManager.RemoveResultsUpdateCallback(self, func)
self._resultsUpdateCallbacks[func] = nil
end
function AuctionScanManager.SetNextSearchItemFunction(self, func)
self._nextSearchItemFunction = func
end
function AuctionScanManager.GetNumQueries(self)
return #self._queries
end
function AuctionScanManager.QueryIterator(self, offset)
return private.QueryIteratorHelper, self._queries, offset or 0
end
function AuctionScanManager.NewQuery(self)
local query = Query.Get()
self:_AddQuery(query)
return query
end
function AuctionScanManager.AddItemListQueriesThreaded(self, itemList)
assert(Threading.IsThreadContext())
-- remove duplicates
local usedItems = TempTable.Acquire()
for i = #itemList, 1, -1 do
local itemString = itemList[i]
if usedItems[itemString] then
tremove(itemList, i)
end
usedItems[itemString] = true
end
TempTable.Release(usedItems)
self._numItems = #itemList
QueryUtil.GenerateThreaded(itemList, private.NewQueryCallback, self)
end
function AuctionScanManager.ScanQueriesThreaded(self)
assert(Threading.IsThreadContext())
self._queriesScanned = 0
self._cancelled = false
AuctionHouseWrapper.GetAndResetTotalHookedTime()
self:_NotifyProgressUpdate()
-- loop through each filter to perform
local allSuccess = true
while self._queriesScanned < #self._queries do
local query = self._queries[self._queriesScanned + 1]
-- run the browse query
local querySuccess, numNewResults = self:_ProcessQuery(query)
if not querySuccess then
allSuccess = false
break
end
self._queriesScanned = self._queriesScanned + 1
self:_NotifyProgressUpdate()
if self._onQueryDoneHandler then
self:_onQueryDoneHandler(query, numNewResults)
end
self:_Pause()
end
if allSuccess then
local hookedTime, topAddon, topTime = AuctionHouseWrapper.GetAndResetTotalHookedTime()
if hookedTime > 1 and topAddon ~= "Blizzard_AuctionHouseUI" then
Log.PrintfUser(L["Scan was slowed down by %s seconds by other AH addons (%s seconds by %s)."], Math.Round(hookedTime, 0.1), Math.Round(topTime, 0.1), topAddon)
end
end
return allSuccess
end
function AuctionScanManager.FindAuctionThreaded(self, findSubRow, noSeller)
assert(Threading.IsThreadContext())
wipe(self._findResult)
if TSM.IsWowClassic() then
return self:_FindAuctionThreaded(findSubRow, noSeller)
else
return self:_FindAuctionThreaded83(findSubRow, noSeller)
end
end
function AuctionScanManager.PrepareForBidOrBuyout(self, index, subRow, noSeller, quantity, itemBuyout)
if TSM.IsWowClassic() then
return subRow:EqualsIndex(index, noSeller)
else
local itemString = subRow:GetItemString()
if ItemInfo.IsCommodity(itemString) then
local future = AuctionHouseWrapper.StartCommoditiesPurchase(ItemString.ToId(itemString), quantity, itemBuyout)
if not future then
return false
end
return true, future
else
return true
end
end
end
function AuctionScanManager.PlaceBidOrBuyout(self, index, bidBuyout, subRow, quantity)
if TSM.IsWowClassic() then
PlaceAuctionBid("list", index, bidBuyout)
return true
else
local itemString = subRow:GetItemString()
local future = nil
if ItemInfo.IsCommodity(itemString) then
local itemId = ItemString.ToId(itemString)
future = AuctionHouseWrapper.ConfirmCommoditiesPurchase(itemId, quantity)
else
local _, auctionId = subRow:GetListingInfo()
future = AuctionHouseWrapper.PlaceBid(auctionId, bidBuyout)
quantity = 1
end
if not future then
return false
end
-- TODO: return this future and record the buyout once the future is done
future:Cancel()
return true
end
end
function AuctionScanManager.GetProgress(self)
local numQueries = self:GetNumQueries()
if self._queriesScanned == numQueries then
return 1
end
local currentQuery = self._queries[self._queriesScanned + 1]
local searchProgress = nil
if not self._queryDidBrowse or TSM.IsWowClassic() then
searchProgress = 0
else
searchProgress = currentQuery:GetSearchProgress() * (1 - BROWSE_PROGRESS) + BROWSE_PROGRESS
end
local queryStep = 1 / numQueries
local progress = min((self._queriesScanned + searchProgress) * queryStep, 1)
return progress, self._paused
end
function AuctionScanManager.Cancel(self)
self._cancelled = true
if self._scanQuery then
self._scanQuery:CancelBrowseOrSearch()
self._scanQuery = nil
end
end
function AuctionScanManager.SetPaused(self, paused)
self._shouldPause = paused
if self._scanQuery then
self._scanQuery:CancelBrowseOrSearch()
self._scanQuery = nil
end
end
function AuctionScanManager.GetNumItems(self)
return self._numItems
end
-- ============================================================================
-- Private Class Methods
-- ============================================================================
function AuctionScanManager._AddQuery(self, query)
query:SetResolveSellers(self._resolveSellers)
query:SetCallback(self._queryCallback)
tinsert(self._queries, query)
end
function AuctionScanManager._IsCancelled(self)
return self._cancelled
end
function AuctionScanManager._Pause(self)
if not self._shouldPause then
return
end
self._paused = true
self:_NotifyProgressUpdate()
if self._currentSearchChangedCallback then
self:_currentSearchChangedCallback()
end
while self._shouldPause do
Threading.Yield(true)
end
self._paused = false
self:_NotifyProgressUpdate()
if self._currentSearchChangedCallback then
self:_currentSearchChangedCallback()
end
end
function AuctionScanManager._NotifyProgressUpdate(self)
if self._onProgressUpdateHandler then
self:_onProgressUpdateHandler()
end
end
function AuctionScanManager._ProcessQuery(self, query)
local prevMaxBrowseId = 0
for _, row in query:BrowseResultsIterator() do
prevMaxBrowseId = max(prevMaxBrowseId, row:GetMinBrowseId())
end
-- run the browse query
self._queryDidBrowse = false
while not self:_DoBrowse(query) do
if self._shouldPause then
-- this browse failed due to a pause request, so try again after we're resumed
self:_Pause()
-- wipe the browse results since we're going to do another search
query:WipeBrowseResults()
else
return false, 0
end
end
self._queryDidBrowse = true
self:_NotifyProgressUpdate()
local numNewResults = 0
if TSM.IsWowClassic() then
for _, row in query:BrowseResultsIterator() do
if row:GetMinBrowseId() > prevMaxBrowseId then
numNewResults = numNewResults + row:GetNumSubRows()
end
end
return true, numNewResults
end
local rows = Threading.AcquireSafeTempTable()
for baseItemString, row in query:BrowseResultsIterator() do
rows[baseItemString] = row
end
while true do
local baseItemString, row = nil, nil
if self._nextSearchItemFunction then
baseItemString = self._nextSearchItemFunction()
row = baseItemString and rows[baseItemString]
end
if not row then
baseItemString, row = next(rows)
end
if not row then
break
end
rows[baseItemString] = nil
if self._currentSearchChangedCallback then
self:_currentSearchChangedCallback(baseItemString)
end
-- store all the existing auctionIds so we can see what changed
local prevAuctionIds = Threading.AcquireSafeTempTable()
for _, subRow in row:SubRowIterator() do
local _, auctionId = subRow:GetListingInfo()
assert(not prevAuctionIds[auctionId])
prevAuctionIds[auctionId] = true
end
-- send the query for this item
while not self:_DoSearch(query, row) do
if self._shouldPause then
-- this search failed due to a pause request, so try again after we're resumed
self:_Pause()
-- wipe the search results since we're going to do another search
row:WipeSearchResults()
else
Threading.ReleaseSafeTempTable(prevAuctionIds)
Threading.ReleaseSafeTempTable(rows)
return false, numNewResults
end
end
local numSubRows = row:GetNumSubRows()
for _, subRow in row:SubRowIterator() do
local _, auctionId = subRow:GetListingInfo()
if not prevAuctionIds[auctionId] then
numNewResults = numNewResults + 1
end
end
Threading.ReleaseSafeTempTable(prevAuctionIds)
if numSubRows == 0 then
-- remove this row since there are no search results
query:RemoveResultRow(row)
end
self:_NotifyProgressUpdate()
self:_Pause()
Threading.Yield()
end
Threading.ReleaseSafeTempTable(rows)
return true, numNewResults
end
function AuctionScanManager._DoBrowse(self, query, ...)
return self:_DoBrowseSearchHelper(query, query:Browse(...))
end
function AuctionScanManager._DoSearch(self, query, ...)
return self:_DoBrowseSearchHelper(query, query:Search(...))
end
function AuctionScanManager._DoBrowseSearchHelper(self, query, future)
if not future then
return false
end
self._scanQuery = query
local result = Threading.WaitForFuture(future)
self._scanQuery = nil
Threading.Yield()
return result
end
function AuctionScanManager._FindAuctionThreaded(self, row, noSeller)
self._cancelled = false
-- make sure we're not in the middle of a query where the results are going to change on us
Threading.WaitForFunction(CanSendAuctionQuery)
-- search the current page for the auction
if self:_FindAuctionOnCurrentPage(row, noSeller) then
Log.Info("Found on current page")
return self._findResult
end
-- search for the item
local page, maxPage = 0, nil
while true do
-- query the AH
if self._findQuery then
self._findQuery:Release()
end
local itemString = row:GetItemString()
local level = ItemInfo.GetMinLevel(itemString)
local quality = ItemInfo.GetQuality(itemString)
assert(level and quality)
self._findQuery = Query.Get()
:SetStr(ItemInfo.GetName(itemString), true)
:SetQualityRange(quality, quality)
:SetLevelRange(level, level)
:SetClass(ItemInfo.GetClassId(itemString), ItemInfo.GetSubClassId(itemString))
:SetItems(itemString)
:SetResolveSellers(not noSeller)
:SetPage(page)
local filterSuccess = self:_DoBrowse(self._findQuery)
if self._findQuery then
self._findQuery:Release()
self._findQuery = nil
end
if not filterSuccess then
break
end
-- search this page for the row
if self:_FindAuctionOnCurrentPage(row, noSeller) then
Log.Info("Found auction (%d)", page)
return self._findResult
elseif self:_IsCancelled() then
break
end
local numPages = ceil(select(2, GetNumAuctionItems("list")) / NUM_AUCTION_ITEMS_PER_PAGE)
local canBeLater = private.FindAuctionCanBeOnLaterPage(row)
maxPage = maxPage or numPages - 1
if not canBeLater and page < maxPage then
maxPage = page
end
if canBeLater and page < maxPage then
Log.Info("Trying next page (%d)", page + 1)
page = page + 1
else
return
end
end
end
function AuctionScanManager._FindAuctionOnCurrentPage(self, subRow, noSeller)
local found = false
for i = 1, GetNumAuctionItems("list") do
if subRow:EqualsIndex(i, noSeller) then
tinsert(self._findResult, i)
found = true
end
end
return found
end
function AuctionScanManager._FindAuctionThreaded83(self, findSubRow, noSeller)
assert(findSubRow:IsSubRow())
self._cancelled = false
noSeller = noSeller or findSubRow:IsCommodity()
local row = findSubRow:GetResultRow()
local findHash, findHashNoSeller = findSubRow:GetHashes()
if not self:_DoSearch(row:GetQuery(), row, false) then
return nil
end
local result = nil
-- first try to find a subRow with a full matching hash
for _, subRow in row:SubRowIterator() do
local quantity, numAuctions = subRow:GetQuantities()
local hash = subRow:GetHashes()
if hash == findHash then
result = (result or 0) + quantity * numAuctions
end
end
if result then
return result
end
-- next try to find the first subRow with a matching no-seller hash
local firstHash = nil
for _, subRow in row:SubRowIterator() do
local quantity, numAuctions = subRow:GetQuantities()
local hash, hashNoSeller = subRow:GetHashes()
if (not firstHash or hash == firstHash) and hashNoSeller == findHashNoSeller then
firstHash = hash
result = (result or 0) + quantity * numAuctions
end
end
return result
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.NewQueryCallback(query, self)
self:_AddQuery(query)
end
function private.FindAuctionCanBeOnLaterPage(row)
local pageAuctions = GetNumAuctionItems("list")
if pageAuctions == 0 then
-- there are no auctions on this page, so it cannot be on a later one
return false
end
local _, _, stackSize, _, _, _, _, _, _, buyout, _, _, _, seller, sellerFull = GetAuctionItemInfo("list", pageAuctions)
local itemBuyout = (buyout > 0) and floor(buyout / stackSize) or 0
local _, rowItemBuyout = row:GetBuyouts()
if rowItemBuyout > itemBuyout then
-- item must be on a later page since it would be sorted after the last auction on this page
return true
elseif rowItemBuyout < itemBuyout then
-- item cannot be on a later page since it would be sorted before the last auction on this page
return false
end
local rowStackSize = row:GetQuantities()
if rowStackSize > stackSize then
-- item must be on a later page since it would be sorted after the last auction on this page
return true
elseif rowStackSize < stackSize then
-- item cannot be on a later page since it would be sorted before the last auction on this page
return false
end
seller = private.FixSellerName(seller, sellerFull) or "?"
local rowSeller = row:GetOwnerInfo()
if rowSeller > seller then
-- item must be on a later page since it would be sorted after the last auction on this page
return true
elseif rowSeller < seller then
-- item cannot be on a later page since it would be sorted before the last auction on this page
return false
end
-- all the things we are sorting on are the same, so the auction could be on a later page
return true
end
function private.FixSellerName(seller, sellerFull)
local realm = GetRealmName()
if sellerFull and strjoin("-", seller, realm) ~= sellerFull then
return sellerFull
else
return seller
end
end
function private.QueryIteratorHelper(tbl, index)
index = index + 1
if index > #tbl then
return
end
return index, tbl[index]
end

View File

@@ -0,0 +1,511 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Scanner = TSM.Init("Service.AuctionScanClasses.Scanner")
local Delay = TSM.Include("Util.Delay")
local FSM = TSM.Include("Util.FSM")
local Future = TSM.Include("Util.Future")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local Event = TSM.Include("Util.Event")
local ItemInfo = TSM.Include("Service.ItemInfo")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local Util = TSM.Include("Service.AuctionScanClasses.Util")
local ResultRow = TSM.Include("Service.AuctionScanClasses.ResultRow")
local private = {
resolveSellers = nil,
pendingFuture = nil,
query = nil,
browseResults = nil,
callback = nil,
browseId = 1,
browseIsNoScan = false,
browseIndex = 1,
browsePendingIndexes = {},
searchRow = nil,
useCachedData = nil,
retryCount = 0,
requestFuture = Future.New("AUCTION_SCANNER_FUTURE"),
requestResult = nil,
fsm = nil,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
Scanner:OnModuleLoad(function()
private.requestFuture:SetScript("OnCleanup", function()
Delay.Cancel("AUCTION_SCANNER_DONE")
private.fsm:ProcessEvent("EV_CANCEL")
end)
if TSM.IsWowClassic() then
Event.Register("AUCTION_ITEM_LIST_UPDATE", function()
private.fsm:SetLoggingEnabled(false)
private.fsm:ProcessEvent("EV_BROWSE_RESULTS_UPDATED")
private.fsm:SetLoggingEnabled(true)
end)
else
Event.Register("COMMODITY_SEARCH_RESULTS_UPDATED", function()
private.fsm:ProcessEvent("EV_SEARCH_RESULTS_UPDATED")
end)
Event.Register("ITEM_SEARCH_RESULTS_UPDATED", function()
private.fsm:ProcessEvent("EV_SEARCH_RESULTS_UPDATED")
end)
end
private.fsm = FSM.New("AUCTION_SCANNER_FSM")
:AddState(FSM.NewState("ST_INIT")
:SetOnEnter(function()
private.query = nil
private.resolveSellers = nil
private.useCachedData = nil
private.searchRow = nil
private.callback = nil
private.retryCount = 0
Delay.Cancel("AUCTION_SCANNER_RETRY")
if private.pendingFuture then
private.pendingFuture:Cancel()
private.pendingFuture = nil
end
end)
:AddTransition("ST_BROWSE_SORT")
:AddTransition("ST_BROWSE_CHECKING")
:AddTransition("ST_SEARCH_GET_KEY")
:AddEvent("EV_START_BROWSE", function(_, query, resolveSellers, browseResults, callback)
assert(not private.query)
private.query = query
private.resolveSellers = resolveSellers
private.browseResults = browseResults
private.browseId = private.browseId + 1
private.browseIsNoScan = false
private.callback = callback
return "ST_BROWSE_SORT"
end)
:AddEvent("EV_START_BROWSE_NO_SCAN", function(_, query, itemKeys, browseResults, callback)
assert(not TSM.IsWowClassic())
assert(not private.query)
private.query = query
private.browseResults = browseResults
private.browseId = private.browseId + 1
private.browseIsNoScan = true
private.callback = callback
for _, itemKey in ipairs(itemKeys) do
local baseItemString = ItemString.GetBaseFromItemKey(itemKey)
private.ProcessBrowseResult(baseItemString, itemKey)
end
return "ST_BROWSE_CHECKING"
end)
:AddEvent("EV_START_SEARCH", function(_, query, resolveSellers, useCachedData, searchRow, callback)
assert(not TSM.IsWowClassic())
assert(not private.query)
private.query = query
private.resolveSellers = resolveSellers
private.useCachedData = useCachedData
private.searchRow = searchRow
private.callback = callback
private.searchRow:SearchReset()
return "ST_SEARCH_GET_KEY"
end)
)
:AddState(FSM.NewState("ST_BROWSE_SORT")
:SetOnEnter(function()
if not private.query:_SetSort() then
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.5, private.RetryHandler)
return
end
return "ST_BROWSE_SEND"
end)
:AddTransition("ST_BROWSE_SORT")
:AddTransition("ST_BROWSE_SEND")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_RETRY", "ST_BROWSE_SORT")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_BROWSE_SEND")
:SetOnEnter(function()
private.HandleAuctionHouseWrapperResult(private.query:_SendWowQuery())
end)
:AddTransition("ST_BROWSE_SEND")
:AddTransition("ST_BROWSE_CHECKING")
:AddTransition("ST_CANCELING")
:AddEvent("EV_FUTURE_SUCCESS", function()
if TSM.IsWowClassic() then
private.browseIndex = 1
wipe(private.browsePendingIndexes)
else
for _, result in ipairs(C_AuctionHouse.GetBrowseResults()) do
local baseItemString = ItemString.GetBaseFromItemKey(result.itemKey)
private.ProcessBrowseResult(baseItemString, result.itemKey, result.minPrice, result.totalQuantity)
end
end
return "ST_BROWSE_CHECKING"
end)
:AddEventTransition("EV_RETRY", "ST_BROWSE_SEND")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_BROWSE_CHECKING")
:SetOnEnter(function()
if not private.query:_BrowseIsPageValid() then
-- this page isn't valid, so go to the next page
return "ST_BROWSE_REQUEST_MORE"
elseif not private.CheckBrowseResults() then
-- result's aren't valid yet, so check again
Delay.AfterFrame("AUCTION_SCANNER_RETRY", 1, private.RetryHandler)
return
end
-- we're done with this set of browse results
if private.callback then
private.callback(private.query)
end
if private.browseIsNoScan or private.query:_BrowseIsDone() then
-- we're done
return "ST_BROWSE_DONE"
else
-- move on to the next page
return "ST_BROWSE_REQUEST_MORE"
end
end)
:AddTransition("ST_BROWSE_CHECKING")
:AddTransition("ST_BROWSE_DONE")
:AddTransition("ST_BROWSE_REQUEST_MORE")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_RETRY", "ST_BROWSE_CHECKING")
:AddEventTransition("EV_BROWSE_RESULTS_UPDATED", "ST_BROWSE_CHECKING")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_BROWSE_REQUEST_MORE")
:SetOnEnter(function(_, isRetry)
if private.query:_BrowseIsDone(isRetry) then
return "ST_BROWSE_CHECKING"
else
private.HandleAuctionHouseWrapperResult(private.query:_BrowseRequestMore(isRetry))
end
end)
:AddTransition("ST_BROWSE_REQUEST_MORE")
:AddTransition("ST_BROWSE_CHECKING")
:AddTransition("ST_CANCELING")
:AddEvent("EV_FUTURE_SUCCESS", function(_, ...)
if TSM.IsWowClassic() then
private.browseIndex = 1
wipe(private.browsePendingIndexes)
else
local newResults = ...
for _, result in ipairs(newResults) do
local baseItemString = ItemString.GetBaseFromItemKey(result.itemKey)
private.ProcessBrowseResult(baseItemString, result.itemKey, result.minPrice, result.totalQuantity)
end
end
return "ST_BROWSE_CHECKING"
end)
:AddEvent("EV_RETRY", function()
return "ST_BROWSE_REQUEST_MORE", true
end)
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_BROWSE_DONE")
:SetOnEnter(function()
private.HandleRequestDone(true)
return "ST_INIT"
end)
:AddTransition("ST_INIT")
)
:AddState(FSM.NewState("ST_SEARCH_GET_KEY")
:SetOnEnter(function()
assert(not TSM.IsWowClassic())
if not private.searchRow:SearchIsReady() then
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.1, private.RetryHandler)
return
end
return "ST_SEARCH_SEND"
end)
:AddTransition("ST_SEARCH_GET_KEY")
:AddTransition("ST_SEARCH_SEND")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_FUTURE_SUCCESS", "ST_SEARCH_SEND")
:AddEventTransition("EV_RETRY", "ST_SEARCH_GET_KEY")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_SEARCH_SEND")
:SetOnEnter(function()
assert(not TSM.IsWowClassic())
if not AuctionHouseWrapper.IsOpen() then
return "ST_CANCELING"
end
if private.useCachedData and private.searchRow:HasCachedSearchData() then
return "ST_SEARCH_REQUEST_MORE"
end
local future, delayTime = private.searchRow:SearchSend()
if future then
private.HandleAuctionHouseWrapperResult(future)
else
if not delayTime then
Log.Err("Failed to send search query - retrying")
delayTime = 0.5
end
-- try again after a delay
Delay.AfterTime("AUCTION_SCANNER_RETRY", delayTime, private.RetryHandler)
end
end)
:AddTransition("ST_SEARCH_SEND")
:AddTransition("ST_SEARCH_REQUEST_MORE")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_FUTURE_SUCCESS", "ST_SEARCH_REQUEST_MORE")
:AddEventTransition("EV_RETRY", "ST_SEARCH_SEND")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_SEARCH_REQUEST_MORE")
:SetOnEnter(function()
assert(not TSM.IsWowClassic())
local baseItemString = private.searchRow:GetBaseItemString()
-- get if the item is a commodity or not
local isCommodity = ItemInfo.IsCommodity(baseItemString)
if isCommodity == nil then
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.1, private.RetryHandler)
return
end
local isDone, future = private.searchRow:SearchCheckStatus()
if isDone then
return "ST_SEARCH_CHECKING"
elseif future then
private.HandleAuctionHouseWrapperResult(future)
else
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.5, private.RetryHandler)
end
end)
:AddTransition("ST_SEARCH_SEND")
:AddTransition("ST_SEARCH_CHECKING")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_FUTURE_SUCCESS", "ST_SEARCH_CHECKING")
:AddEventTransition("EV_RETRY", "ST_SEARCH_SEND")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_SEARCH_CHECKING")
:SetOnEnter(function()
assert(not TSM.IsWowClassic())
Delay.Cancel("AUCTION_SCANNER_RETRY")
private.searchRow:PopulateSubRows(private.browseId)
-- check if all the sub rows have their data
local isDone = true
for _, subRow in private.searchRow:SubRowIterator(true) do
if not subRow:HasRawData() or not subRow:HasItemString() then
isDone = false
elseif private.resolveSellers and not subRow:HasOwners() and not private.query:_IsFiltered(subRow, true) then
-- waiting for owner info
isDone = false
end
end
if not isDone and private.retryCount >= 100 then
-- out of retries, so give up
return "ST_SEARCH_DONE", false
elseif not isDone then
-- we'll try again
private.retryCount = private.retryCount + 1
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.5, private.RetryHandler)
return
end
-- filter the sub rows we don't care about
private.searchRow:FilterSubRows(private.query)
if private.callback then
private.callback(private.query, private.searchRow)
end
if private.searchRow:SearchNext() then
-- there is more to search
return "ST_SEARCH_GET_KEY"
else
-- scanned everything
return "ST_SEARCH_DONE", true
end
end)
:AddTransition("ST_SEARCH_GET_KEY")
:AddTransition("ST_SEARCH_CHECKING")
:AddTransition("ST_SEARCH_DONE")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_RETRY", "ST_SEARCH_CHECKING")
:AddEventTransition("EV_SEARCH_RESULTS_UPDATED", "ST_SEARCH_CHECKING")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_SEARCH_DONE")
:SetOnEnter(function(_, result)
assert(not TSM.IsWowClassic())
private.HandleRequestDone(result)
return "ST_INIT"
end)
:AddTransition("ST_INIT")
)
:AddState(FSM.NewState("ST_CANCELING")
:SetOnEnter(function()
Delay.Cancel("AUCTION_SCANNER_DONE")
return "ST_INIT"
end)
:AddTransition("ST_INIT")
)
:Init("ST_INIT", nil)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Scanner.Browse(query, resolveSellers, results, callback)
private.requestFuture:Start()
private.fsm:ProcessEvent("EV_START_BROWSE", query, resolveSellers, results, callback)
return private.requestFuture
end
function Scanner.BrowseNoScan(query, itemKeys, results, callback)
assert(not TSM.IsWowClassic())
private.requestFuture:Start()
private.fsm:ProcessEvent("EV_START_BROWSE_NO_SCAN", query, itemKeys, results, callback)
return private.requestFuture
end
function Scanner.Search(query, resolveSellers, useCachedData, browseRow, callback)
assert(not TSM.IsWowClassic())
private.requestFuture:Start()
private.fsm:ProcessEvent("EV_START_SEARCH", query, resolveSellers, useCachedData, browseRow, callback)
return private.requestFuture
end
function Scanner.Cancel()
private.requestFuture:Done(false)
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ProcessBrowseResult(baseItemString, ...)
if private.browseResults[baseItemString] then
private.browseResults[baseItemString]:Merge(...)
else
private.browseResults[baseItemString] = ResultRow.Get(private.query, ...)
end
return private.browseResults[baseItemString]
end
function private.PendingFutureDoneHandler()
local result = private.pendingFuture:GetValue()
private.pendingFuture = nil
if result then
private.fsm:ProcessEvent("EV_FUTURE_SUCCESS", result)
else
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.1, private.RetryHandler)
end
end
function private.RetryHandler()
private.fsm:SetLoggingEnabled(false)
private.fsm:ProcessEvent("EV_RETRY")
private.fsm:SetLoggingEnabled(true)
end
function private.RequestDoneHandler()
local result = private.requestResult
private.requestResult = nil
private.requestFuture:Done(result)
end
function private.HandleAuctionHouseWrapperResult(future)
if future then
private.pendingFuture = future
private.pendingFuture:SetScript("OnDone", private.PendingFutureDoneHandler)
else
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.1, private.RetryHandler)
end
end
function private.HandleRequestDone(result)
private.requestResult = result
-- delay a bit so that we complete our current FSM transition
Delay.AfterTime("AUCTION_SCANNER_DONE", 0, private.RequestDoneHandler)
end
function private.CheckBrowseResults()
if TSM.IsWowClassic() then
-- process as many auctions as we can
local numAuctions = GetNumAuctionItems("list")
for i = #private.browsePendingIndexes, 1, -1 do
local index = private.browsePendingIndexes[i]
if private.ProcessBrowseResultClassic(index) then
tremove(private.browsePendingIndexes, i)
end
end
local index = private.browseIndex
while index <= numAuctions and #private.browsePendingIndexes < 50 do
if not private.ProcessBrowseResultClassic(index) then
tinsert(private.browsePendingIndexes, index)
end
index = index + 1
end
private.browseIndex = index
if private.browseIndex <= numAuctions or #private.browsePendingIndexes > 0 then
return false
end
end
-- check if there's data still pending
local hasPendingData = false
for _, row in pairs(private.browseResults) do
if not row:PopulateBrowseData() then
hasPendingData = true
-- keep going so we issue requests for all pending rows
end
end
if hasPendingData then
return false
end
-- filter the results
local numRemoved = 0
for baseItemString, row in pairs(private.browseResults) do
-- filter the itemKeys we don't care about and rows which don't match the query
if row:IsFiltered(private.query) then
private.browseResults[baseItemString] = nil
numRemoved = numRemoved + 1
end
if TSM.IsWowClassic() then
if row:FilterSubRows(private.query) then
-- no more subRows, so filter the entire row
private.browseResults[baseItemString] = nil
numRemoved = numRemoved + 1
end
end
end
if numRemoved > 0 then
Log.Info("Removed %d results", numRemoved)
end
return true
end
function private.ProcessBrowseResultClassic(index)
local rawName, _, stackSize, _, _, _, _, _, _, buyout, _, _, _, seller, sellerFull = GetAuctionItemInfo("list", index)
local itemLink = GetAuctionItemLink("list", index)
local baseItemString = ItemString.GetBase(itemLink)
local timeLeft = GetAuctionItemTimeLeft("list", index)
seller = Util.FixSellerName(seller, sellerFull)
if not rawName or rawName == "" or not baseItemString or not buyout or not stackSize or not timeLeft or (not seller and private.resolveSellers) then
return false
end
local row = private.ProcessBrowseResult(baseItemString, itemLink)
-- amazingly, GetAuctionItemLink could return nil the next time it's called (within the same frame), so pass through our itemLink
row:PopulateSubRows(private.browseId, index, itemLink)
return true
end

View File

@@ -0,0 +1,38 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Util = TSM.Init("Service.AuctionScanClasses.Util")
local ItemInfo = TSM.Include("Service.ItemInfo")
-- ============================================================================
-- Module Functions
-- ============================================================================
function Util.HasItemInfo(itemString)
local itemName = ItemInfo.GetName(itemString)
local itemLevel = ItemInfo.GetItemLevel(itemString)
local quality = ItemInfo.GetQuality(itemString)
local minLevel = ItemInfo.GetMinLevel(itemString)
local hasIsCommodity = TSM.IsWowClassic() or ItemInfo.IsCommodity(itemString) ~= nil
local hasCanHaveVariations = ItemInfo.CanHaveVariations(itemString) ~= nil
local result = itemName and itemLevel and quality and minLevel and hasIsCommodity and hasCanHaveVariations
if not result then
ItemInfo.FetchInfo(itemString)
end
return result
end
function Util.FixSellerName(seller, sellerFull)
local realm = GetRealmName()
if sellerFull and strjoin("-", seller, realm) ~= sellerFull then
return sellerFull
else
return seller
end
end

View File

@@ -0,0 +1,626 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local AuctionTracking = TSM.Init("Service.AuctionTracking")
local L = TSM.Include("Locale").GetTable()
local Database = TSM.Include("Util.Database")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local TempTable = TSM.Include("Util.TempTable")
local Sound = TSM.Include("Util.Sound")
local Money = TSM.Include("Util.Money")
local Analytics = TSM.Include("Util.Analytics")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Settings = TSM.Include("Service.Settings")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local private = {
settings = nil,
indexDB = nil,
quantityDB = nil,
updateQuery = nil, -- luacheck: ignore 1004 - just stored for GC reasons
isAHOpen = false,
callbacks = {},
expiresCallbacks = {},
indexUpdates = {
list = {},
pending = {},
},
cancelAuctionId = nil,
lastScanNum = nil,
ignoreUpdateEvent = nil,
lastPurchase = {},
prevLineId = nil,
prevLineResult = nil,
origChatFrameOnEvent = nil,
pendingFuture = nil,
auctionIdToLink = {},
auctionIdToItemBuyout = {},
prevLogTime = 0,
prevLogNum = math.huge,
}
local PLAYER_NAME = UnitName("player")
local SALE_HINT_SEP = "\001"
local SALE_HINT_EXPIRE_TIME = 33 * 24 * 60 * 60
local SORT_ORDER = not TSM.IsWowClassic() and {
{ sortOrder = Enum.AuctionHouseSortOrder.Name, reverseSort = false },
{ sortOrder = Enum.AuctionHouseSortOrder.Price, reverseSort = false },
}
local AUCTIONABLE_WOW_TOKEN_ITEM_ID = 122270
-- ============================================================================
-- Module Loading
-- ============================================================================
AuctionTracking:OnSettingsLoad(function()
private.settings = Settings.NewView()
:AddKey("char", "internalData", "auctionSaleHints")
:AddKey("char", "internalData", "auctionPrices")
:AddKey("char", "internalData", "auctionMessages")
:AddKey("factionrealm", "internalData", "expiringAuction")
:AddKey("sync", "internalData", "auctionQuantity")
:AddKey("global", "coreOptions", "auctionSaleSound")
Event.Register("AUCTION_HOUSE_SHOW", private.AuctionHouseShowHandler)
Event.Register("AUCTION_HOUSE_CLOSED", private.AuctionHouseClosedHandler)
if TSM.IsWowClassic() then
Event.Register("AUCTION_OWNED_LIST_UPDATE", private.AuctionOwnedListUpdateHandler)
else
Event.Register("OWNED_AUCTIONS_UPDATED", private.AuctionOwnedListUpdateHandler)
Event.Register("AUCTION_CANCELED", private.AuctionCanceledHandler)
end
private.indexDB = Database.NewSchema("AUCTION_TRACKING_INDEXES")
:AddUniqueNumberField("index")
:AddStringField("itemString")
:AddSmartMapField("baseItemString", ItemString.GetBaseMap(), "itemString")
:AddStringField("itemLink")
:AddNumberField("itemTexture")
:AddStringField("itemName")
:AddNumberField("itemQuality")
:AddNumberField("duration")
:AddStringField("highBidder")
:AddNumberField("currentBid")
:AddNumberField("buyout")
:AddNumberField("stackSize")
:AddNumberField("saleStatus")
:AddNumberField("auctionId")
:AddIndex("index")
:AddIndex("saleStatus")
:AddIndex("auctionId")
:Commit()
private.quantityDB = Database.NewSchema("AUCTION_TRACKING_QUANTITY")
:AddUniqueStringField("itemString")
:AddNumberField("quantity")
:Commit()
private.updateQuery = private.indexDB:NewQuery()
:SetUpdateCallback(private.OnCallbackQueryUpdated)
private.RebuildQuantityDB()
for info, timestamp in pairs(private.settings.auctionSaleHints) do
if time() > timestamp + SALE_HINT_EXPIRE_TIME then
private.settings.auctionSaleHints[info] = nil
end
end
if TSM.IsWowClassic() then
hooksecurefunc("PostAuction", function(_, _, duration)
private.PostAuctionHookHandler(duration)
end)
else
hooksecurefunc(C_AuctionHouse, "PostCommodity", function(_, duration)
private.PostAuctionHookHandler(duration)
end)
hooksecurefunc(C_AuctionHouse, "PostItem", function(_, duration)
private.PostAuctionHookHandler(duration)
end)
hooksecurefunc(C_AuctionHouse, "CancelAuction", function(auctionId)
private.cancelAuctionId = auctionId
end)
end
-- setup enhanced sale / buy messages
ChatFrame_AddMessageEventFilter("CHAT_MSG_SYSTEM", private.FilterSystemMsg)
if TSM.IsWowClassic() then
hooksecurefunc("PlaceAuctionBid", function(_, index, amountPaid)
local link = GetAuctionItemLink("list", index)
local name, _, stackSize, _, _, _, _, _, _, buyout = GetAuctionItemInfo("list", index)
if amountPaid == buyout then
wipe(private.lastPurchase)
private.lastPurchase.name = name
private.lastPurchase.link = link
private.lastPurchase.stackSize = stackSize
private.lastPurchase.buyout = buyout
end
end)
else
Event.Register("ITEM_SEARCH_RESULTS_UPDATED", function(_, itemKey)
wipe(private.auctionIdToLink)
wipe(private.auctionIdToItemBuyout)
for i = 1, C_AuctionHouse.GetNumItemSearchResults(itemKey) do
local info = C_AuctionHouse.GetItemSearchResultInfo(itemKey, i)
if info.buyoutAmount then
private.auctionIdToLink[info.auctionID] = info.itemLink
private.auctionIdToItemBuyout[info.auctionID] = info.buyoutAmount
end
end
end)
hooksecurefunc(C_AuctionHouse, "PlaceBid", function(auctionId, bidPlaced)
local link = private.auctionIdToLink[auctionId]
local buyout = private.auctionIdToItemBuyout[auctionId]
if not link or buyout ~= bidPlaced then
return
end
wipe(private.lastPurchase)
private.lastPurchase.name = ItemInfo.GetName(link)
private.lastPurchase.link = link
private.lastPurchase.stackSize = 1
private.lastPurchase.buyout = bidPlaced
end)
hooksecurefunc(C_AuctionHouse, "ConfirmCommoditiesPurchase", function(itemId, quantity)
local link = ItemInfo.GetLink("i:"..itemId)
if not link then
return
end
local origQuantity = quantity
local buyout = 0
for i = 1, C_AuctionHouse.GetNumCommoditySearchResults(itemId) do
local info = C_AuctionHouse.GetCommoditySearchResultInfo(itemId, i)
local resultQuantity = min(quantity, info.quantity - info.numOwnerItems)
buyout = buyout + resultQuantity * info.unitPrice
quantity = quantity - resultQuantity
if quantity == 0 then
break
end
end
if quantity > 0 then
return
end
private.lastPurchase.name = ItemInfo.GetName(link)
private.lastPurchase.link = link
private.lastPurchase.stackSize = origQuantity
private.lastPurchase.buyout = buyout
end)
end
end)
AuctionTracking:OnGameDataLoad(function()
-- setup auction created / cancelled filtering
-- NOTE: this is delayed until the game is loaded to avoid taint issues
local ElvUIChat, ElvUIChatIsEnabled = nil, nil
if IsAddOnLoaded("ElvUI") and ElvUI then
ElvUIChat = ElvUI[1]:GetModule("Chat")
if ElvUI[3].chat.enable then
ElvUIChatIsEnabled = true
end
end
if ElvUIChatIsEnabled then
private.origChatFrameOnEvent = ElvUIChat.ChatFrame_OnEvent
ElvUIChat.ChatFrame_OnEvent = private.ChatFrameOnEvent
else
private.origChatFrameOnEvent = ChatFrame_OnEvent
ChatFrame_OnEvent = private.ChatFrameOnEvent
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function AuctionTracking.RegisterCallback(callback)
tinsert(private.callbacks, callback)
end
function AuctionTracking.RegisterExpiresCallback(callback)
tinsert(private.expiresCallbacks, callback)
end
function AuctionTracking.DatabaseFieldIterator()
return private.indexDB:FieldIterator()
end
function AuctionTracking.BaseItemIterator()
return private.quantityDB:NewQuery()
:Select("itemString")
:IteratorAndRelease()
end
function AuctionTracking.CreateQuery()
return private.indexDB:NewQuery()
end
function AuctionTracking.CreateQueryUnsold()
return AuctionTracking.CreateQuery()
:Equal("saleStatus", 0)
end
function AuctionTracking.CreateQueryUnsoldItem(itemString)
return AuctionTracking.CreateQueryUnsold()
:Equal(itemString == ItemString.GetBaseFast(itemString) and "baseItemString" or "itemString", itemString)
end
function AuctionTracking.GetSaleHintItemString(name, stackSize, buyout)
for info in pairs(private.settings.auctionSaleHints) do
local infoName, itemString, infoStackSize, infoBuyout = strsplit(SALE_HINT_SEP, info)
if infoName == name and tonumber(infoStackSize) == stackSize and tonumber(infoBuyout) == buyout then
return itemString
end
end
end
function AuctionTracking.GetQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "quantity") or 0
end
function AuctionTracking.QueryOwnedAuctions()
if not private.isAHOpen then
return
end
if TSM.IsWowClassic() then
GetOwnerAuctionItems()
else
if private.pendingFuture then
return
end
private.pendingFuture = AuctionHouseWrapper.QueryOwnedAuctions(SORT_ORDER)
if not private.pendingFuture then
Delay.AfterTime(0.5, AuctionTracking.QueryOwnedAuctions)
return
end
private.pendingFuture:SetScript("OnDone", private.PendingFutureOnDone)
end
end
-- ============================================================================
-- Event Handlers
-- ============================================================================
function private.AuctionHouseShowHandler()
private.isAHOpen = true
if TSM.IsWowClassic() then
AuctionTracking.QueryOwnedAuctions()
-- We don't always get AUCTION_OWNED_LIST_UPDATE events, so do our own scanning if needed
Delay.AfterTime("AUCTION_BACKGROUND_SCAN", 1, private.DoBackgroundScan, 1)
else
Delay.AfterTime(0.1, AuctionTracking.QueryOwnedAuctions)
end
end
function private.AuctionHouseClosedHandler()
private.isAHOpen = false
Delay.Cancel("AUCTION_BACKGROUND_SCAN")
end
function private.DoBackgroundScan()
if private.GetNumOwnedAuctions() ~= private.lastScanNum then
private.AuctionOwnedListUpdateHandler()
end
end
function private.AuctionOwnedListUpdateHandler()
if private.ignoreUpdateEvent then
return
end
wipe(private.indexUpdates.pending)
wipe(private.indexUpdates.list)
local numOwned = private.GetNumOwnedAuctions()
for i = 1, numOwned do
if not private.indexUpdates.pending[i] then
private.indexUpdates.pending[i] = true
tinsert(private.indexUpdates.list, i)
end
end
if numOwned == 0 and private.settings.expiringAuction[PLAYER_NAME] then
private.settings.expiringAuction[PLAYER_NAME] = nil
for _, callback in ipairs(private.expiresCallbacks) do
callback()
end
end
Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 2, private.AuctionOwnedListUpdateDelayed)
end
function private.AuctionCanceledHandler(_, auctionId)
if not private.cancelAuctionId or auctionId ~= 0 then
-- an auction was bought, so rescan the owned auctions
AuctionTracking.QueryOwnedAuctions()
return
end
local row = private.indexDB:NewQuery()
:Equal("auctionId", private.cancelAuctionId)
:GetFirstResultAndRelease()
private.cancelAuctionId = nil
if not row then
return
end
local baseItemString = row:GetField("baseItemString")
local stackSize = row:GetField("stackSize")
assert(stackSize <= private.settings.auctionQuantity[baseItemString])
private.settings.auctionQuantity[baseItemString] = private.settings.auctionQuantity[baseItemString] - stackSize
private.RebuildQuantityDB()
private.indexDB:DeleteRow(row)
row:Release()
end
function private.AuctionOwnedListUpdateDelayed()
if not private.isAHOpen then
return
elseif AuctionFrame and AuctionFrame:IsVisible() and AuctionFrame.selectedTab == 3 then
-- default UI auctions tab is visible, so scan later
Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 2, private.AuctionOwnedListUpdateDelayed)
return
elseif not TSM.IsWowClassic() and not C_AuctionHouse.HasFullOwnedAuctionResults() then
-- don't have all the results yet, so try again in a moment
Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 0.1, private.AuctionOwnedListUpdateDelayed)
return
end
if TSM.IsWowClassic() then
-- check if we need to change the sort
local needsSort = false
local numColumns = #AuctionSort.owner_duration
for i, info in ipairs(AuctionSort.owner_duration) do
local col, reversed = GetAuctionSort("owner", numColumns - i + 1)
-- we want to do the opposite order
reversed = not reversed
if col ~= info.column or info.reverse ~= reversed then
needsSort = true
break
end
end
if needsSort then
Log.Info("Sorting owner auctions")
-- ignore events while changing the sort
private.ignoreUpdateEvent = true
AuctionFrame_SetSort("owner", "duration", true)
SortAuctionApplySort("owner")
private.ignoreUpdateEvent = nil
end
end
-- scan the auctions
local shouldLog = GetTime() - private.prevLogTime > 5
if shouldLog then
private.prevLogTime = GetTime()
end
wipe(private.settings.auctionQuantity)
private.indexDB:TruncateAndBulkInsertStart()
local expire = math.huge
for i = #private.indexUpdates.list, 1, -1 do
local index = private.indexUpdates.list[i]
local auctionId, link, name, texture, stackSize, quality, minBid, buyout, bid, highBidder, saleStatus, duration, shouldIgnore = private.GetOwnedAuctionInfo(index)
if shouldIgnore then
private.indexUpdates.pending[index] = nil
tremove(private.indexUpdates.list, i)
else
name = name or ItemInfo.GetName(link)
texture = texture or ItemInfo.GetTexture(link)
quality = quality or ItemInfo.GetQuality(link)
if link and name and texture and quality then
assert(saleStatus == 0 or saleStatus == 1)
highBidder = highBidder or ""
local itemString = ItemString.Get(link)
local currentBid = highBidder ~= "" and bid or minBid
if not currentBid and saleStatus == 1 and not TSM.IsWowClassic() then
-- sometimes wow doesn't tell us the current bid on sold auctions on retail
currentBid = 0
end
if saleStatus == 0 then
if TSM.IsWowClassic() then
if duration == 1 then -- 30 min
expire = min(expire, time() + 0.5 * 60 * 60)
elseif duration == 2 then -- 2 hours
expire = min(expire, time() + 2 * 60 * 60)
elseif duration == 3 then -- 12 hours
expire = min(expire, time() + 12 * 60 * 60)
end
else
duration = time() + duration
expire = min(expire, duration)
end
local baseItemString = ItemString.GetBaseFast(itemString)
private.settings.auctionQuantity[baseItemString] = (private.settings.auctionQuantity[baseItemString] or 0) + stackSize
local hintInfo = strjoin(SALE_HINT_SEP, ItemInfo.GetName(link), itemString, stackSize, buyout)
private.settings.auctionSaleHints[hintInfo] = time()
else
duration = time() + duration
end
private.indexUpdates.pending[index] = nil
tremove(private.indexUpdates.list, i)
private.indexDB:BulkInsertNewRow(index, itemString, link, texture, name, quality, duration, highBidder, currentBid, buyout, stackSize, saleStatus, auctionId)
elseif shouldLog then
Log.Warn("Missing info (%s, %s, %s, %s)", gsub(tostring(link), "\124", "\\124"), tostring(name), tostring(texture), tostring(quality))
if link and strmatch(link, "item:") and not TSM.IsWowClassic() then
Analytics.Action("AUCTION_TRACKING_MISSING_INFO", link)
end
end
end
end
private.RebuildQuantityDB()
private.indexDB:BulkInsertEnd()
if expire ~= math.huge and (private.settings.expiringAuction[PLAYER_NAME] or math.huge) > expire then
private.settings.expiringAuction[PLAYER_NAME] = expire
for _, callback in ipairs(private.expiresCallbacks) do
callback()
end
end
if shouldLog or #private.indexUpdates.list ~= private.prevLogNum then
Log.Info("Scanned auctions (left=%d)", #private.indexUpdates.list)
private.prevLogNum = #private.indexUpdates.list
end
if #private.indexUpdates.list > 0 then
-- some failed to scan so try again
Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 2, private.AuctionOwnedListUpdateDelayed)
else
private.lastScanNum = private.GetNumOwnedAuctions()
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.RebuildQuantityDB()
private.quantityDB:TruncateAndBulkInsertStart()
for itemString, quantity in pairs(private.settings.auctionQuantity) do
if quantity > 0 then
private.quantityDB:BulkInsertNewRow(itemString, quantity)
else
private.settings.auctionQuantity[itemString] = nil
end
end
private.quantityDB:BulkInsertEnd()
end
function private.GetNumOwnedAuctions()
if TSM.IsWowClassic() then
return GetNumAuctionItems("owner")
else
return C_AuctionHouse.GetNumOwnedAuctions()
end
end
function private.GetOwnedAuctionInfo(index)
if TSM.IsWowClassic() then
local name, texture, stackSize, quality, _, _, _, minBid, _, buyout, bid, highBidder, _, _, _, saleStatus = GetAuctionItemInfo("owner", index)
local link = name and name ~= "" and GetAuctionItemLink("owner", index)
if not link then
return
end
local duration = GetAuctionItemTimeLeft("owner", index)
return index, link, name, texture, stackSize, quality, minBid, buyout, bid, highBidder, saleStatus, duration
else
local info = C_AuctionHouse.GetOwnedAuctionInfo(index)
if info.itemKey.itemID == AUCTIONABLE_WOW_TOKEN_ITEM_ID then
-- this is a token, so just ignore it
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, true
end
local link = info and info.itemLink
if not link then
return
end
local bid = info.bidAmount or info.buyoutAmount
local minBid = bid
return info.auctionID, link, nil, nil, info.quantity, nil, minBid, info.buyoutAmount or 0, bid, info.bidder or "", info.status, info.timeLeftSeconds
end
end
function private.OnCallbackQueryUpdated()
for _, callback in ipairs(private.callbacks) do
callback()
end
-- updating the auction prices / messages is very low-priority, so throttle it to at most every 0.5 seconds
Delay.AfterTime("UPDATE_AUCTION_PRICES_MESSAGES_THROTTLE", 0.5, private.UpdateAuctionPricesMessages)
end
function private.PostAuctionHookHandler(duration)
local days = nil
if duration == 1 then
days = 0.5
elseif duration == 2 then
days = 1
elseif duration == 3 then
days = 2
end
local expiration = time() + (days * 24 * 60 * 60)
if (private.settings.expiringAuction[PLAYER_NAME] or math.huge) < expiration then
return
end
private.settings.expiringAuction[PLAYER_NAME] = expiration
for _, callback in ipairs(private.expiresCallbacks) do
callback()
end
end
function private.UpdateAuctionPricesMessages()
local INVALID_STACK_SIZE = -1
-- recycle tables from private.settings.auctionPrices if we can so we're not creating a ton of garbage
local freeTables = TempTable.Acquire()
for _, tbl in pairs(private.settings.auctionPrices) do
wipe(tbl)
tinsert(freeTables, tbl)
end
wipe(private.settings.auctionPrices)
wipe(private.settings.auctionMessages)
local auctionPrices = TempTable.Acquire()
local auctionStackSizes = TempTable.Acquire()
local query = AuctionTracking.CreateQueryUnsold()
:Select("itemLink", "stackSize", "buyout")
:GreaterThan("buyout", 0)
:OrderBy("index", true)
for _, link, stackSize, buyout in query:IteratorAndRelease() do
auctionPrices[link] = auctionPrices[link] or tremove(freeTables) or {}
if stackSize ~= auctionStackSizes[link] then
auctionStackSizes[link] = stackSize
end
tinsert(auctionPrices[link], buyout)
end
for link, prices in pairs(auctionPrices) do
local name = ItemInfo.GetName(link)
if auctionStackSizes[link] ~= INVALID_STACK_SIZE then
sort(prices)
private.settings.auctionPrices[link] = prices
private.settings.auctionMessages[format(ERR_AUCTION_SOLD_S, name)] = link
end
end
TempTable.Release(freeTables)
TempTable.Release(auctionPrices)
TempTable.Release(auctionStackSizes)
end
function private.ChatFrameOnEvent(self, event, msg, ...)
-- surpress auction created / cancelled spam
if event == "CHAT_MSG_SYSTEM" and (msg == ERR_AUCTION_STARTED or msg == ERR_AUCTION_REMOVED) then
return
end
return private.origChatFrameOnEvent(self, event, msg, ...)
end
function private.FilterSystemMsg(_, _, msg, ...)
local lineID = select(10, ...)
if lineID ~= private.prevLineId then
private.prevLineId = lineID
private.prevLineResult = nil
local link = private.settings.auctionMessages and private.settings.auctionMessages[msg]
if private.lastPurchase.name and msg == format(ERR_AUCTION_WON_S, private.lastPurchase.name) then
-- we just bought an auction
private.prevLineResult = format(L["You won an auction for %sx%d for %s"], private.lastPurchase.link, private.lastPurchase.stackSize, Money.ToString(private.lastPurchase.buyout, "|cffffffff"))
return nil, private.prevLineResult, ...
elseif link then
-- we may have just sold an auction
local price = tremove(private.settings.auctionPrices[link], 1)
local numAuctions = #private.settings.auctionPrices[link]
if not price then
-- couldn't determine the price, so just replace the link
private.prevLineResult = format(ERR_AUCTION_SOLD_S, link)
Sound.PlaySound(private.settings.auctionSaleSound)
return nil, private.prevLineResult, ...
end
if numAuctions == 0 then -- this was the last auction
private.settings.auctionMessages[msg] = nil
end
private.prevLineResult = format(L["Your auction of %s has sold for %s!"], link, Money.ToString(price, "|cffffffff"))
Sound.PlaySound(private.settings.auctionSaleSound)
return nil, private.prevLineResult, ...
end
end
end
function private.PendingFutureOnDone()
-- we also hook the event, so don't care what the result is
private.pendingFuture:GetValue()
private.pendingFuture = nil
end

View File

@@ -0,0 +1,615 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local BagTracking = TSM.Init("Service.BagTracking")
local Database = TSM.Include("Util.Database")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local SlotId = TSM.Include("Util.SlotId")
local Log = TSM.Include("Util.Log")
local TempTable = TSM.Include("Util.TempTable")
local ItemString = TSM.Include("Util.ItemString")
local ItemInfo = TSM.Include("Service.ItemInfo")
local InventoryInfo = TSM.Include("Service.InventoryInfo")
local Settings = TSM.Include("Service.Settings")
local private = {
slotDB = nil,
quantityDB = nil,
settings = nil,
bagUpdates = {
pending = {},
bagList = {},
bankList = {},
},
bankSlotUpdates = {
pending = {},
list = {},
},
reagentBankSlotUpdates = {
pending = {},
list = {},
},
bankOpen = false,
isFirstBankOpen = true,
callbackQuery = nil, -- luacheck: ignore 1004 - just stored for GC reasons
callbacks = {},
}
local BANK_BAG_SLOTS = {}
local BANK_NON_REAGENT_BAG_SLOTS = {}
-- ============================================================================
-- Population of the Static Data
-- ============================================================================
do
BANK_BAG_SLOTS[BANK_CONTAINER] = true
BANK_NON_REAGENT_BAG_SLOTS[BANK_CONTAINER] = true
for i = NUM_BAG_SLOTS + 1, NUM_BAG_SLOTS + NUM_BANKBAGSLOTS do
BANK_BAG_SLOTS[i] = true
BANK_NON_REAGENT_BAG_SLOTS[i] = true
end
if not TSM.IsWowClassic() then
BANK_BAG_SLOTS[REAGENTBANK_CONTAINER] = true
end
end
-- ============================================================================
-- Module Loading
-- ============================================================================
BagTracking:OnSettingsLoad(function()
Event.Register("BAG_UPDATE", private.BagUpdateHandler)
Event.Register("BAG_UPDATE_DELAYED", private.BagUpdateDelayedHandler)
Event.Register("BANKFRAME_OPENED", private.BankOpenedHandler)
Event.Register("BANKFRAME_CLOSED", private.BankClosedHandler)
Event.Register("PLAYERBANKSLOTS_CHANGED", private.BankSlotChangedHandler)
if not TSM.IsWowClassic() then
Event.Register("PLAYERREAGENTBANKSLOTS_CHANGED", private.ReagentBankSlotChangedHandler)
end
private.slotDB = Database.NewSchema("BAG_TRACKING_SLOTS")
:AddUniqueNumberField("slotId")
:AddNumberField("bag")
:AddNumberField("slot")
:AddStringField("itemLink")
:AddStringField("itemString")
:AddSmartMapField("baseItemString", ItemString.GetBaseMap(), "itemString")
:AddNumberField("itemTexture")
:AddNumberField("quantity")
:AddBooleanField("isBoP")
:AddBooleanField("isBoA")
:AddIndex("slotId")
:AddIndex("bag")
:AddIndex("itemString")
:AddIndex("baseItemString")
:Commit()
private.quantityDB = Database.NewSchema("BAG_TRACKING_QUANTITY")
:AddUniqueStringField("itemString")
:AddNumberField("bagQuantity")
:AddNumberField("bankQuantity")
:AddNumberField("reagentBankQuantity")
:Commit()
private.callbackQuery = private.slotDB:NewQuery()
:SetUpdateCallback(private.OnCallbackQueryUpdated)
private.settings = Settings.NewView()
:AddKey("sync", "internalData", "bagQuantity")
:AddKey("sync", "internalData", "bankQuantity")
:AddKey("sync", "internalData", "reagentBankQuantity")
local items = TempTable.Acquire()
local bagQuantity = TempTable.Acquire()
local bankQuantity = TempTable.Acquire()
local reagentBankQuantity = TempTable.Acquire()
for itemString, quantity in pairs(private.settings.bagQuantity) do
if itemString == ItemString.GetBase(itemString) then
items[itemString] = true
bagQuantity[itemString] = quantity
else
private.settings.bagQuantity[itemString] = nil
end
end
for itemString, quantity in pairs(private.settings.bankQuantity) do
if itemString == ItemString.GetBase(itemString) then
items[itemString] = true
bankQuantity[itemString] = quantity
else
private.settings.bankQuantity[itemString] = nil
end
end
for itemString, quantity in pairs(private.settings.reagentBankQuantity) do
if itemString == ItemString.GetBase(itemString) then
items[itemString] = true
reagentBankQuantity[itemString] = quantity
else
private.settings.reagentBankQuantity[itemString] = nil
end
end
private.quantityDB:BulkInsertStart()
for itemString in pairs(items) do
local total = (bagQuantity[itemString] or 0) + (bankQuantity[itemString] or 0) + (reagentBankQuantity[itemString] or 0)
if total > 0 then
private.quantityDB:BulkInsertNewRow(itemString, bagQuantity[itemString] or 0, bankQuantity[itemString] or 0, reagentBankQuantity[itemString] or 0)
end
end
private.quantityDB:BulkInsertEnd()
TempTable.Release(items)
TempTable.Release(bagQuantity)
TempTable.Release(bankQuantity)
TempTable.Release(reagentBankQuantity)
end)
BagTracking:OnGameDataLoad(function()
-- we'll scan all the bags and reagent bank right away, so wipe the existing quantities
wipe(private.settings.bagQuantity)
wipe(private.settings.reagentBankQuantity)
private.quantityDB:SetQueryUpdatesPaused(true)
local query = private.quantityDB:NewQuery()
for _, row in query:Iterator() do
local oldValue = row:GetField("bagQuantity") + row:GetField("reagentBankQuantity")
if row:GetField("bankQuantity") == 0 then
-- remove this row
assert(oldValue > 0)
private.quantityDB:DeleteRow(row)
elseif oldValue ~= 0 then
-- update this row
row:SetField("bagQuantity", 0)
:SetField("reagentBankQuantity", 0)
:Update()
end
end
query:Release()
private.quantityDB:SetQueryUpdatesPaused(false)
-- WoW does not fire an update event for the backpack when you log in, so trigger one
private.BagUpdateHandler(nil, 0)
private.BagUpdateDelayedHandler()
-- trigger an update event for all bank (initial container) and reagent bank slots since we won't get one otherwise on login
assert(GetContainerNumSlots(BANK_CONTAINER) == NUM_BANKGENERIC_SLOTS)
for slot = 1, GetContainerNumSlots(BANK_CONTAINER) do
private.BankSlotChangedHandler(nil, slot)
end
if not TSM.IsWowClassic() and IsReagentBankUnlocked() then
for slot = 1, GetContainerNumSlots(REAGENTBANK_CONTAINER) do
private.ReagentBankSlotChangedHandler(nil, slot)
end
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function BagTracking.RegisterCallback(callback)
tinsert(private.callbacks, callback)
end
function BagTracking.BaseItemIterator()
return private.quantityDB:NewQuery()
:Select("itemString")
:IteratorAndRelease()
end
function BagTracking.FilterQueryBags(query)
return query
:GreaterThanOrEqual("slotId", SlotId.Join(0, 1))
:LessThanOrEqual("slotId", SlotId.Join(NUM_BAG_SLOTS + 1, 0))
end
function BagTracking.CreateQueryBags()
return BagTracking.FilterQueryBags(private.slotDB:NewQuery())
end
function BagTracking.CreateQueryBagsAuctionable()
return BagTracking.CreateQueryBags()
:Equal("isBoP", false)
:Equal("isBoA", false)
:Custom(private.NoUsedChargesQueryFilter)
end
function BagTracking.CreateQueryBagsItem(itemString)
local query = BagTracking.CreateQueryBags()
if itemString == ItemString.GetBaseFast(itemString) then
query:Equal("baseItemString", itemString)
else
query:Equal("itemString", itemString)
end
return query
end
function BagTracking.CreateQueryBagsItemAuctionable(itemString)
return BagTracking.CreateQueryBagsItem(itemString)
:Equal("isBoP", false)
:Equal("isBoA", false)
:Custom(private.NoUsedChargesQueryFilter)
end
function BagTracking.GetNumMailable(itemString)
return BagTracking.CreateQueryBagsItem(itemString)
:Equal("isBoP", false)
:SumAndRelease("quantity") or 0
end
function BagTracking.CreateQueryBank()
return private.slotDB:NewQuery()
:InTable("bag", BANK_BAG_SLOTS)
end
function BagTracking.CreateQueryBankItem(itemString)
local query = BagTracking.CreateQueryBank()
if itemString == ItemString.GetBaseFast(itemString) then
query:Equal("baseItemString", itemString)
else
query:Equal("itemString", itemString)
end
return query
end
function BagTracking.ForceBankQuantityDeduction(itemString, quantity)
if private.bankOpen then
return
end
private.slotDB:SetQueryUpdatesPaused(true)
local query = private.slotDB:NewQuery()
:Equal("itemString", itemString)
:InTable("bag", BANK_NON_REAGENT_BAG_SLOTS)
local baseItemString = ItemString.GetBaseFast(itemString)
for _, row in query:Iterator() do
if quantity > 0 then
local rowQuantity, rowBag = row:GetFields("quantity", "bag")
if rowQuantity <= quantity then
private.ChangeBagItemTotal(rowBag, baseItemString, -rowQuantity)
private.slotDB:DeleteRow(row)
quantity = quantity - rowQuantity
else
row:SetField("quantity", rowQuantity - quantity)
:Update()
private.ChangeBagItemTotal(rowBag, baseItemString, -quantity)
quantity = 0
end
end
end
query:Release()
private.slotDB:SetQueryUpdatesPaused(false)
end
function BagTracking.GetQuantityBySlotId(slotId)
return private.slotDB:GetUniqueRowField("slotId", slotId, "quantity")
end
function BagTracking.GetBagsQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "bagQuantity") or 0
end
function BagTracking.GetBankQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "bankQuantity") or 0
end
function BagTracking.GetReagentBankQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "reagentBankQuantity") or 0
end
-- ============================================================================
-- Event Handlers
-- ============================================================================
function private.BankOpenedHandler()
if private.isFirstBankOpen then
private.isFirstBankOpen = false
-- this is the first time opening the bank so we'll scan all the items so wipe our existing quantities
wipe(private.settings.bankQuantity)
private.quantityDB:SetQueryUpdatesPaused(true)
local query = private.quantityDB:NewQuery()
for _, row in query:Iterator() do
local oldValue = row:GetField("bankQuantity")
if row:GetField("bagQuantity") + row:GetField("reagentBankQuantity") == 0 then
-- remove this row
assert(oldValue > 0)
private.quantityDB:DeleteRow(row)
elseif oldValue ~= 0 then
-- update this row
row:SetField("bankQuantity", 0)
:Update()
end
end
query:Release()
private.quantityDB:SetQueryUpdatesPaused(false)
end
private.bankOpen = true
private.BagUpdateDelayedHandler()
private.BankSlotUpdateDelayed()
end
function private.BankClosedHandler()
private.bankOpen = false
end
function private.BagUpdateHandler(_, bag)
if private.bagUpdates.pending[bag] then
return
end
private.bagUpdates.pending[bag] = true
if bag >= BACKPACK_CONTAINER and bag <= NUM_BAG_SLOTS then
tinsert(private.bagUpdates.bagList, bag)
elseif bag == BANK_CONTAINER or (bag > NUM_BAG_SLOTS and bag <= NUM_BAG_SLOTS + NUM_BANKBAGSLOTS) then
tinsert(private.bagUpdates.bankList, bag)
elseif bag ~= KEYRING_CONTAINER then
error("Unexpected bag: "..tostring(bag))
end
end
function private.BagUpdateDelayedHandler()
private.slotDB:SetQueryUpdatesPaused(true)
-- scan any pending bags
for i = #private.bagUpdates.bagList, 1, -1 do
local bag = private.bagUpdates.bagList[i]
if private.ScanBagOrBank(bag) then
private.bagUpdates.pending[bag] = nil
tremove(private.bagUpdates.bagList, i)
end
end
if #private.bagUpdates.bagList > 0 then
-- some failed to scan so try again
Delay.AfterFrame("bagBankScan", 2, private.BagUpdateDelayedHandler)
end
if private.bankOpen then
-- scan any pending bank bags
for i = #private.bagUpdates.bankList, 1, -1 do
local bag = private.bagUpdates.bankList[i]
if private.ScanBagOrBank(bag) then
private.bagUpdates.pending[bag] = nil
tremove(private.bagUpdates.bankList, i)
end
end
if #private.bagUpdates.bankList > 0 then
-- some failed to scan so try again
Delay.AfterFrame("bagBankScan", 2, private.BagUpdateDelayedHandler)
end
end
private.slotDB:SetQueryUpdatesPaused(false)
end
function private.BankSlotChangedHandler(_, slot)
if slot > NUM_BANKGENERIC_SLOTS then
private.BagUpdateHandler(nil, slot - NUM_BANKGENERIC_SLOTS)
return
end
if private.bankSlotUpdates.pending[slot] then
return
end
private.bankSlotUpdates.pending[slot] = true
tinsert(private.bankSlotUpdates.list, slot)
Delay.AfterFrame("bankSlotScan", 2, private.BankSlotUpdateDelayed)
end
-- this is not a WoW event, but we fake it based on a delay from private.BankSlotChangedHandler
function private.BankSlotUpdateDelayed()
if not private.bankOpen then
return
end
private.slotDB:SetQueryUpdatesPaused(true)
-- scan any pending slots
for i = #private.bankSlotUpdates.list, 1, -1 do
local slot = private.bankSlotUpdates.list[i]
if private.ScanBankSlot(slot) then
private.bankSlotUpdates.pending[slot] = nil
tremove(private.bankSlotUpdates.list, i)
end
end
if #private.bankSlotUpdates.list > 0 then
-- some failed to scan so try again
Delay.AfterFrame("bankSlotScan", 2, private.BankSlotUpdateDelayed)
end
private.slotDB:SetQueryUpdatesPaused(false)
end
function private.ReagentBankSlotChangedHandler(_, slot)
if private.reagentBankSlotUpdates.pending[slot] then
return
end
private.reagentBankSlotUpdates.pending[slot] = true
tinsert(private.reagentBankSlotUpdates.list, slot)
Delay.AfterFrame("reagentBankSlotScan", 2, private.ReagentBankSlotUpdateDelayed)
end
-- this is not a WoW event, but we fake it based on a delay from private.ReagentBankSlotChangedHandler
function private.ReagentBankSlotUpdateDelayed()
private.slotDB:SetQueryUpdatesPaused(true)
-- scan any pending slots
for i = #private.reagentBankSlotUpdates.list, 1, -1 do
local slot = private.reagentBankSlotUpdates.list[i]
if private.ScanReagentBankSlot(slot) then
private.reagentBankSlotUpdates.pending[slot] = nil
tremove(private.reagentBankSlotUpdates.list, i)
end
end
if #private.reagentBankSlotUpdates.list > 0 then
-- some failed to scan so try again
Delay.AfterFrame("reagentBankSlotScan", 2, private.ReagentBankSlotUpdateDelayed)
end
private.slotDB:SetQueryUpdatesPaused(false)
end
-- ============================================================================
-- Scanning Functions
-- ============================================================================
function private.ScanBagOrBank(bag)
local numSlots = GetContainerNumSlots(bag)
private.RemoveExtraSlots(bag, numSlots)
local result = true
for slot = 1, numSlots do
if not private.ScanBagSlot(bag, slot) then
result = false
end
end
return result
end
function private.ScanBankSlot(slot)
return private.ScanBagSlot(BANK_CONTAINER, slot)
end
function private.ScanReagentBankSlot(slot)
return private.ScanBagSlot(REAGENTBANK_CONTAINER, slot)
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.NoUsedChargesQueryFilter(row)
return not InventoryInfo.HasUsedCharges(row:GetFields("bag", "slot"))
end
function private.RemoveExtraSlots(bag, numSlots)
-- the number of slots of this bag may have changed, in which case we should remove any higher ones from our DB
local query = private.slotDB:NewQuery()
:Equal("bag", bag)
:GreaterThan("slot", numSlots)
for _, row in query:Iterator() do
local baseItemString, quantity = row:GetFields("baseItemString", "quantity")
private.ChangeBagItemTotal(bag, baseItemString, -quantity)
private.slotDB:DeleteRow(row)
end
query:Release()
end
function private.ScanBagSlot(bag, slot)
local texture, quantity, _, _, _, _, link, _, _, itemId = GetContainerItemInfo(bag, slot)
if quantity and not itemId then
-- we are pending item info for this slot so try again later to scan it
return false
elseif quantity == 0 then
-- this item is going away, so try again later to scan it
return false
end
local baseItemString = link and ItemString.GetBase(link)
local slotId = SlotId.Join(bag, slot)
local row = private.slotDB:GetUniqueRow("slotId", slotId)
if baseItemString then
local isBoP, isBoA = nil, nil
if row then
if row:GetField("itemLink") == link then
-- the item didn't change, so use the previous values
isBoP, isBoA = row:GetFields("isBoP", "isBoA")
else
isBoP, isBoA = InventoryInfo.IsSoulbound(bag, slot)
if isBoP == nil then
Log.Err("Failed to get soulbound info for %d,%d (%s)", bag, slot, link or "?")
return false
end
end
-- remove the old row from the item totals
local oldBaseItemString, oldQuantity = row:GetFields("baseItemString", "quantity")
private.ChangeBagItemTotal(bag, oldBaseItemString, -oldQuantity)
else
isBoP, isBoA = InventoryInfo.IsSoulbound(bag, slot)
if isBoP == nil then
Log.Err("Failed to get soulbound info for %d,%d (%s)", bag, slot, link or "?")
return false
end
-- there was nothing here previously so create a new row
row = private.slotDB:NewRow()
:SetField("slotId", slotId)
:SetField("bag", bag)
:SetField("slot", slot)
end
-- update the row
row:SetField("itemLink", link)
:SetField("itemString", ItemString.Get(link))
:SetField("itemTexture", texture or ItemInfo.GetTexture(link))
:SetField("quantity", quantity)
:SetField("isBoP", isBoP)
:SetField("isBoA", isBoA)
:CreateOrUpdateAndRelease()
-- add to the item totals
private.ChangeBagItemTotal(bag, baseItemString, quantity)
elseif row then
-- nothing here now so delete the row and remove from the item totals
local oldBaseItemString, oldQuantity = row:GetFields("baseItemString", "quantity")
private.ChangeBagItemTotal(bag, oldBaseItemString, -oldQuantity)
private.slotDB:DeleteRow(row)
row:Release()
end
return true
end
function private.OnCallbackQueryUpdated()
for _, callback in ipairs(private.callbacks) do
callback()
end
end
function private.ChangeBagItemTotal(bag, itemString, changeQuantity)
local totalsTable = nil
local field = nil
if bag >= BACKPACK_CONTAINER and bag <= NUM_BAG_SLOTS then
totalsTable = private.settings.bagQuantity
field = "bagQuantity"
elseif bag == BANK_CONTAINER or (bag > NUM_BAG_SLOTS and bag <= NUM_BAG_SLOTS + NUM_BANKBAGSLOTS) then
totalsTable = private.settings.bankQuantity
field = "bankQuantity"
elseif bag == REAGENTBANK_CONTAINER then
totalsTable = private.settings.reagentBankQuantity
field = "reagentBankQuantity"
else
error("Unexpected bag: "..tostring(bag))
end
totalsTable[itemString] = (totalsTable[itemString] or 0) + changeQuantity
private.UpdateQuantity(itemString, field, changeQuantity)
assert(totalsTable[itemString] >= 0)
if totalsTable[itemString] == 0 then
totalsTable[itemString] = nil
end
end
function private.UpdateQuantity(itemString, field, quantity)
assert(itemString and field and quantity)
assert(quantity ~= 0)
if not private.quantityDB:HasUniqueRow("itemString", itemString) then
-- create a new row
private.quantityDB:NewRow()
:SetField("itemString", itemString)
:SetField("bagQuantity", 0)
:SetField("bankQuantity", 0)
:SetField("reagentBankQuantity", 0)
:Create()
end
local row = private.quantityDB:GetUniqueRow("itemString", itemString)
local totalQuantity = row:GetField("bagQuantity") + row:GetField("bankQuantity") + row:GetField("reagentBankQuantity")
local oldValue = row:GetField(field)
local newValue = oldValue + quantity
assert(newValue >= 0)
if newValue == 0 and totalQuantity == oldValue then
-- remove this row
private.quantityDB:DeleteRow(row)
else
-- update this row
row:SetField(field, oldValue + quantity)
:Update()
end
row:Release()
end

View File

@@ -0,0 +1,67 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
-- This file contains all the code for TSM's standalone features
local _, TSM = ...
local BlackMarket = TSM.Init("Service.BlackMarket")
local Event = TSM.Include("Util.Event")
local TempTable = TSM.Include("Util.TempTable")
local ItemString = TSM.Include("Util.ItemString")
local private = {
data = nil,
time = nil,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
BlackMarket:OnModuleLoad(function()
-- setup BMAH scanning
if not TSM.IsWowClassic() then
Event.Register("BLACK_MARKET_ITEM_UPDATE", private.ScanBMAH)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function BlackMarket.GetScanData()
return private.data, private.time
end
-- ============================================================================
-- Private Helper Features
-- ============================================================================
function private.ScanBMAH()
local numItems = C_BlackMarket.GetNumItems()
if not numItems then
return
end
local items = TempTable.Acquire()
for i = 1, numItems do
local _, _, quantity, _, _, _, _, _, minBid, minIncr, currBid, _, numBids, timeLeft, itemLink, bmId = C_BlackMarket.GetItemInfoByIndex(i)
local itemID = ItemString.ToId(itemLink)
if itemID then
minBid = floor(minBid / COPPER_PER_GOLD)
minIncr = floor(minIncr / COPPER_PER_GOLD)
currBid = floor(currBid / COPPER_PER_GOLD)
tinsert(items, "[" .. strjoin(",", bmId, itemID, quantity, timeLeft, minBid, minIncr, currBid, numBids, time()) .. "]")
end
end
private.data = "[" .. table.concat(items, ",") .. "]"
private.time = time()
TempTable.Release(items)
end

View File

@@ -0,0 +1,182 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Conversions = TSM.Init("Service.Conversions")
local DisenchantInfo = TSM.Include("Data.DisenchantInfo")
local Mill = TSM.Include("Data.Mill")
local Prospect = TSM.Include("Data.Prospect")
local Transform = TSM.Include("Data.Transform")
local VendorTrade = TSM.Include("Data.VendorTrade")
local TempTable = TSM.Include("Util.TempTable")
local ItemString = TSM.Include("Util.ItemString")
local Table = TSM.Include("Util.Table")
local ItemInfo = TSM.Include("Service.ItemInfo")
Conversions.METHOD = {
DISENCHANT = newproxy(),
MILL = newproxy(),
PROSPECT = newproxy(),
TRANSFORM = newproxy(),
VENDOR_TRADE = newproxy(),
CRAFT = newproxy(),
}
local private = {
data = {},
sourceItemCache = {},
skippedConversions = {},
}
local MAX_CONVERSION_DEPTH = 3
local EMPTY_CONVERSION = newproxy()
-- ============================================================================
-- Module Loading
-- ============================================================================
Conversions:OnModuleLoad(function()
Table.SetReadOnly(Conversions.METHOD)
for itemString in DisenchantInfo.TargetItemIterator() do
ItemInfo.FetchInfo(itemString)
end
for targetItemString in Mill.TargetItemIterator() do
for sourceItemString in Mill.SourceItemIterator(targetItemString) do
local rate = Mill.GetRate(targetItemString, sourceItemString)
private.Add(targetItemString, sourceItemString, Conversions.METHOD.MILL, rate)
end
end
for targetItemString in Prospect.TargetItemIterator() do
for sourceItemString in Prospect.SourceItemIterator(targetItemString) do
local rate, amount, minAmount, maxAmount = Prospect.GetRate(targetItemString, sourceItemString)
private.Add(targetItemString, sourceItemString, Conversions.METHOD.PROSPECT, rate, amount, minAmount, maxAmount)
end
end
for targetItemString in Transform.TargetItemIterator() do
for sourceItemString in Transform.SourceItemIterator(targetItemString) do
local rate = Transform.GetRate(targetItemString, sourceItemString)
private.Add(targetItemString, sourceItemString, Conversions.METHOD.TRANSFORM, rate)
end
end
for targetItemString in VendorTrade.TargetItemIterator() do
for sourceItemString in VendorTrade.SourceItemIterator(targetItemString) do
local rate = VendorTrade.GetRate(targetItemString, sourceItemString)
private.Add(targetItemString, sourceItemString, Conversions.METHOD.VENDOR_TRADE, rate)
end
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Conversions.AddCraft(targetItemString, sourceItemString, rate)
private.Add(targetItemString, sourceItemString, Conversions.METHOD.CRAFT, rate)
end
function Conversions.TargetItemsByMethodIterator(sourceItemString, method)
local context = TempTable.Acquire()
context.sourceItemString = sourceItemString
context.method = method
return private.TargetItemsByMethodIteratorHelper, context, nil
end
function Conversions.GetTargetItemByName(targetItemName)
targetItemName = strlower(targetItemName)
for targetItemString in pairs(private.data) do
local name = ItemInfo.GetName(targetItemString)
if name and strlower(name) == targetItemName then
return targetItemString
end
end
for targetItemString in DisenchantInfo.TargetItemIterator() do
local name = ItemInfo.GetName(targetItemString)
if name and strlower(name) == targetItemName then
return targetItemString
end
end
end
function Conversions.GetSourceItems(targetItemString)
if not targetItemString or not private.data[targetItemString] or private.sourceItemCache[targetItemString] == EMPTY_CONVERSION then
return
end
if not private.sourceItemCache[targetItemString] then
local depthLookup = TempTable.Acquire()
depthLookup[targetItemString] = -1 -- set this so we don't loop back through the target item
private.sourceItemCache[targetItemString] = {}
private.GetSourceItemsHelper(targetItemString, private.sourceItemCache[targetItemString], depthLookup, 0, 1)
TempTable.Release(depthLookup)
if not next(private.sourceItemCache[targetItemString]) then
private.sourceItemCache[targetItemString] = EMPTY_CONVERSION
return
end
end
return private.sourceItemCache[targetItemString]
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.Add(targetItemString, sourceItemString, method, rate, amount, minAmount, maxAmount)
targetItemString = ItemString.GetBase(targetItemString)
sourceItemString = ItemString.GetBase(sourceItemString)
assert(targetItemString and sourceItemString)
private.data[targetItemString] = private.data[targetItemString] or {}
if private.data[targetItemString][sourceItemString] then
-- if there is more than one way to go from source to target, then just skip all conversions between these items
private.skippedConversions[targetItemString..sourceItemString] = true
private.data[targetItemString][sourceItemString] = nil
end
if private.skippedConversions[targetItemString..sourceItemString] then
return
end
private.data[targetItemString][sourceItemString] = {
method = method,
rate = rate,
amount = amount,
minAmount = minAmount,
maxAmount = maxAmount,
}
ItemInfo.FetchInfo(targetItemString)
ItemInfo.FetchInfo(sourceItemString)
wipe(private.sourceItemCache)
end
function private.GetSourceItemsHelper(targetItemString, result, depthLookup, currentDepth, currentRate)
if currentDepth >= MAX_CONVERSION_DEPTH or not private.data[targetItemString] then
return
end
for sourceItemString, info in pairs(private.data[targetItemString]) do
if not result[sourceItemString] or depthLookup[sourceItemString] > currentDepth then
local rate = info.rate * currentRate
result[sourceItemString] = rate
depthLookup[sourceItemString] = currentDepth
private.GetSourceItemsHelper(sourceItemString, result, depthLookup, currentDepth + 1, rate)
end
end
end
function private.TargetItemsByMethodIteratorHelper(context, index)
while true do
index = next(private.data, index)
local items = private.data[index]
if not items then
TempTable.Release(context)
return
end
local info = items[context.sourceItemString]
if info and ((not context.method and info.method ~= Conversions.METHOD.CRAFT) or info.method == context.method) then
return index, info.rate, info.amount, info.minAmount, info.maxAmount
end
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,769 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
-- TSM's error handler
local _, TSM = ...
local ErrorHandler = TSM.Init("Service.ErrorHandler")
local Log = TSM.Include("Util.Log")
local String = TSM.Include("Util.String")
local Event = TSM.Include("Util.Event")
local JSON = TSM.Include("Util.JSON")
local TempTable = TSM.Include("Util.TempTable")
local L = TSM.Include("Locale").GetTable()
local private = {
origErrorHandler = nil,
errorFrame = nil,
isSilent = nil,
errorSuppressed = nil,
errorReports = {},
num = 0,
localLinesTemp = {},
hitInternalError = false,
isManual = nil,
ignoreErrors = false,
globalNameTranslation = {},
}
local MAX_ERROR_REPORT_AGE = 7 * 24 * 60 * 60 -- 1 week
local MAX_STACK_DEPTH = 50
local ADDON_SUITES = {
"ArkInventory",
"AtlasLoot",
"Altoholic",
"Auc-",
"Bagnon",
"BigWigs",
"Broker",
"ButtonFacade",
"Carbonite",
"DataStore",
"DBM",
"Dominos",
"DXE",
"EveryQuest",
"Forte",
"FuBar",
"GatherMate2",
"Grid",
"LightHeaded",
"LittleWigs",
"Masque",
"MogIt",
"Odyssey",
"Overachiever",
"PitBull4",
"Prat-3.0",
"RaidAchievement",
"Skada",
"SpellFlash",
"TidyPlates",
"TipTac",
"Titan",
"UnderHood",
"WowPro",
"ZOMGBuffs",
}
local OLD_TSM_MODULES = {
"TradeSkillMaster_Accounting",
"TradeSkillMaster_AuctionDB",
"TradeSkillMaster_Auctioning",
"TradeSkillMaster_Crafting",
"TradeSkillMaster_Destroying",
"TradeSkillMaster_Mailing",
"TradeSkillMaster_Shopping",
"TradeSkillMaster_Vendoring",
"TradeSkillMaster_Warehousing",
}
local PRINT_PREFIX = "|cffff0000TSM:|r "
-- ============================================================================
-- Module Functions
-- ============================================================================
function ErrorHandler.ShowForThread(err, thread)
local stackLine = debugstack(thread, 0, 1, 0)
local oldModule = strmatch(stackLine, "(lMaster_[A-Za-z]+)")
if oldModule and tContains(OLD_TSM_MODULES, "TradeSkil"..oldModule) then
-- ignore errors from old modules
return
end
-- show an error, but don't cause an exception to be thrown
private.isSilent = true
private.ErrorHandler(err, thread)
end
function ErrorHandler.ShowManual()
private.isManual = true
-- show an error, but don't cause an exception to be thrown
private.isSilent = true
private.ErrorHandler("Manually triggered error")
end
function ErrorHandler.SaveReports(appDB)
if private.errorFrame then
private.errorFrame:Hide()
end
appDB.errorReports = appDB.errorReports or { updateTime = 0, data = {} }
if #private.errorReports > 0 then
appDB.errorReports.updateTime = private.errorReports[#private.errorReports].timestamp
end
-- remove any events which are too old
for i = #appDB.errorReports.data, 1, -1 do
local timestamp = strmatch(appDB.errorReports.data[i], "([0-9]+)%]$") or ""
if (tonumber(timestamp) or 0) < time() - MAX_ERROR_REPORT_AGE then
tremove(appDB.errorReports.data, i)
end
end
for _, report in ipairs(private.errorReports) do
local line = format("[%s,\"%s\",%d]", JSON.Encode(report.errorInfo), report.details, report.timestamp)
tinsert(appDB.errorReports.data, line)
end
end
-- ============================================================================
-- Error Handler
-- ============================================================================
function private.ErrorHandler(msg, thread)
-- ignore errors while we are handling this error
private.ignoreErrors = true
local isSilent = private.isSilent
private.isSilent = nil
local isManual = private.isManual
private.isManual = nil
private.CreateErrorFrame()
if type(thread) ~= "thread" then
thread = nil
end
if private.errorFrame:IsVisible() and private.errorSuppressed then
-- already showing an error and suppressed another one, so silently ignore this one
private.ignoreErrors = false
return true
end
-- shorten the paths in the error message
msg = gsub(msg, "%.%.%.T?r?a?d?e?S?k?i?l?l?M?a?ster([_A-Za-z]*)\\", "TradeSkillMaster%1\\")
msg = strsub(msg, strfind(msg, "TradeSkillMaster") or 1)
msg = gsub(msg, "TradeSkillMaster([^%.])", "TSM%1")
-- build our global name translation table
wipe(private.globalNameTranslation)
pcall(function()
local UIElements = TSM.Include("UI.UIElements")
local temp = {}
UIElements.GetDebugNameTranslation(temp)
for k, v in pairs(temp) do
private.globalNameTranslation[String.Escape(k)] = v
end
end)
-- build stack trace with locals and get addon name
local stackInfo = private.GetStackInfo(msg, thread)
local addonName = isSilent and "TradeSkillMaster" or nil
for _, info in ipairs(stackInfo) do
if not addonName then
addonName = strmatch(info.file, "[A-Za-z]+%.lua") and private.IsTSMAddon(info.file) or nil
end
end
if not isManual and addonName ~= "TradeSkillMaster" then
-- not a TSM error
private.ignoreErrors = false
return false
end
if not TSM.IsDevVersion() and not isManual then
-- log the error (use a format string in case there are '%' characters in the msg)
Log.Err("%s", msg)
end
if private.errorFrame:IsVisible() then
-- already showing an error, so suppress this one and return
private.errorSuppressed = true
print(PRINT_PREFIX..L["Additional error suppressed"])
return true
end
private.num = private.num + 1
local clientVersion, clientBuild = GetBuildInfo()
local errorInfo = {
msg = #stackInfo > 0 and gsub(msg, String.Escape(stackInfo[1].file)..":"..stackInfo[1].line..": ", "") or msg,
stackInfo = stackInfo,
time = time(),
debugTime = floor(debugprofilestop()),
client = format("%s (%s)", clientVersion, clientBuild),
locale = GetLocale(),
inCombat = tostring(InCombatLockdown() and true or false),
version = TSM.GetVersion(),
}
-- temp table info
local tempTableLines = {}
for _, info in ipairs(TempTable.GetDebugInfo()) do
tinsert(tempTableLines, info)
end
errorInfo.tempTableStr = table.concat(tempTableLines, "\n")
-- object pool info
local status, objectPoolInfo = pcall(function() return TSM.Include("Util.ObjectPool").GetDebugInfo() end)
local objectPoolLines = {}
if status then
for name, objectInfo in pairs(objectPoolInfo) do
tinsert(objectPoolLines, format("%s (%d created, %d in use)", name, objectInfo.numCreated, objectInfo.numInUse))
for _, info in ipairs(objectInfo.info) do
tinsert(objectPoolLines, " "..info)
end
end
end
errorInfo.objectPoolStr = status and table.concat(objectPoolLines, "\n") or tostring(objectPoolInfo)
-- TSM thread info
local threadInfoStr = nil
status, threadInfoStr = pcall(function() return TSM.Include("Service.Threading").GetDebugStr() end)
errorInfo.threadInfoStr = tostring(threadInfoStr)
-- recent debug log entries
local entries = {}
for i = Log.Length(), 1, -1 do
local severity, location, timeStr, logMsg = Log.Get(i)
tinsert(entries, format("%s [%s] {%s} %s", timeStr, severity, location, logMsg))
end
errorInfo.debugLogStr = table.concat(entries, "\n")
-- addons
local hasAddonSuite = {}
local addonsLines = {}
for i = 1, GetNumAddOns() do
local name, _, _, loadable = GetAddOnInfo(i)
if loadable then
local version = strtrim(GetAddOnMetadata(name, "X-Curse-Packaged-Version") or GetAddOnMetadata(name, "Version") or "")
local loaded = IsAddOnLoaded(i)
local isSuite = nil
for _, commonTerm in ipairs(ADDON_SUITES) do
if strsub(name, 1, #commonTerm) == commonTerm then
isSuite = commonTerm
break
end
end
local commonTerm = "TradeSkillMaster"
if isSuite then
if not hasAddonSuite[isSuite] then
tinsert(addonsLines, name.." ("..version..")"..(loaded and "" or " [Not Loaded]"))
hasAddonSuite[isSuite] = true
end
elseif strsub(name, 1, #commonTerm) == commonTerm then
name = gsub(name, "TradeSkillMaster", "TSM")
tinsert(addonsLines, name.." ("..version..")"..(loaded and "" or " [Not Loaded]"))
else
tinsert(addonsLines, name.." ("..version..")"..(loaded and "" or " [Not Loaded]"))
end
end
end
errorInfo.addonsStr = table.concat(addonsLines, "\n")
-- show this error
local stackInfoLines = {}
for _, info in ipairs(errorInfo.stackInfo) do
local localsStr = info.locals ~= "" and ("\n |cffaaaaaa"..gsub(info.locals, "\n", "\n ").."|r") or ""
local locationStr = info.line ~= 0 and strjoin(":", info.file, info.line) or info.file
tinsert(stackInfoLines, locationStr.." <"..info.func..">"..localsStr)
end
private.errorFrame.errorStr = strjoin("\n",
private.FormatErrorMessageSection("Message", msg),
private.FormatErrorMessageSection("Time", date("%m/%d/%y %H:%M:%S", errorInfo.time).." ("..floor(errorInfo.debugTime)..")"),
private.FormatErrorMessageSection("Client", errorInfo.client),
private.FormatErrorMessageSection("Locale", errorInfo.locale),
private.FormatErrorMessageSection("Combat", errorInfo.inCombat),
private.FormatErrorMessageSection("Error Count", private.num),
private.FormatErrorMessageSection("Stack Trace", table.concat(stackInfoLines, "\n"), true),
private.FormatErrorMessageSection("Temp Tables", errorInfo.tempTableStr, true),
private.FormatErrorMessageSection("Object Pools", errorInfo.objectPoolStr, true),
private.FormatErrorMessageSection("Running Threads", errorInfo.threadInfoStr, true),
private.FormatErrorMessageSection("Debug Log", errorInfo.debugLogStr, true),
private.FormatErrorMessageSection("Addons", errorInfo.addonsStr, true)
)
-- remove unprintable characters
private.errorFrame.errorStr = gsub(private.errorFrame.errorStr, "[%z\001-\008\011-\031]", "?")
private.errorFrame.errorInfo = errorInfo
private.errorFrame.isManual = isManual
private.errorFrame:Show()
print(PRINT_PREFIX..L["Looks like TradeSkillMaster has encountered an error. Please help the author fix this error by following the instructions shown."])
if TSM.__IS_TEST_ENV then
print(private.errorFrame.errorStr)
end
private.ignoreErrors = false
return true
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetStackInfo(msg, thread)
local errLocation = strmatch(msg, "[A-Za-z]+%.lua:[0-9]+")
local stackInfo = {}
local stackStarted = false
for i = 0, MAX_STACK_DEPTH do
local prevStackFunc = #stackInfo > 0 and stackInfo[#stackInfo].func or nil
local file, line, func, localsStr, newPrevStackFunc = private.GetStackLevelInfo(i, thread, prevStackFunc)
if newPrevStackFunc then
stackInfo[#stackInfo].func = newPrevStackFunc
end
if file then
if not stackStarted then
if errLocation then
stackStarted = strmatch(file..":"..line, "[A-Za-z]+%.lua:[0-9]+") == errLocation
else
stackStarted = i > (thread and 1 or 4) and file ~= "[C]"
end
end
if stackStarted then
tinsert(stackInfo, {
file = file,
line = line,
func = func,
locals = localsStr,
})
end
end
end
return stackInfo
end
function private.GetStackLevelInfo(level, thread, prevStackFunc)
local stackLine = nil
if thread then
stackLine = debugstack(thread, level, 1, 0)
else
level = level + 1
stackLine = debugstack(level, 1, 0)
end
stackLine = gsub(stackLine, "^%[string \"@([^%.]+%.lua)\"%]", "%1")
local locals = debuglocals(level)
stackLine = gsub(stackLine, "%.%.%.T?r?a?d?e?S?k?i?l?l?M?a?ster([_A-Za-z]*)\\", "TradeSkillMaster%1\\")
stackLine = gsub(stackLine, "%.%.%.", "")
stackLine = gsub(stackLine, "`", "<", 1)
stackLine = gsub(stackLine, "'", ">", 1)
stackLine = strtrim(stackLine)
if stackLine == "" then
return
end
-- Parse out the file, line, and function name
local locationStr, functionStr = strmatch(stackLine, "^(.-): in function (<[^\n]*>)")
if not locationStr then
locationStr, functionStr = strmatch(stackLine, "^(.-): in (main chunk)")
end
if not locationStr then
return
end
locationStr = strsub(locationStr, strfind(locationStr, "TradeSkillMaster") or 1)
locationStr = gsub(locationStr, "TradeSkillMaster([^%.])", "TSM%1")
functionStr = functionStr and gsub(gsub(functionStr, ".*\\", ""), "[<>]", "") or ""
local file, line = strmatch(locationStr, "^(.+):(%d+)$")
file = file or locationStr
line = tonumber(line) or 0
local func = strsub(functionStr, strfind(functionStr, "`") and 2 or 1, -1) or "?"
func = func ~= "" and func or "?"
if strfind(locationStr, "LibTSMClass%.lua:") then
-- ignore stack frames from the class code's wrapper function
if func ~= "?" and prevStackFunc and not strmatch(func, "^.+:[0-9]+$") and strmatch(prevStackFunc, "^.+:[0-9]+$") then
-- this stack frame includes the class method we were accessing in the previous one, so go back and fix it up
local className = locals and strmatch(locals, "\n +str = \"([A-Za-z_0-9]+):[0-9A-F]+\"\n") or "?"
prevStackFunc = className.."."..func
end
return nil, nil, nil, nil, prevStackFunc
end
-- add locals for addon functions (debuglocals() doesn't always work - or ever for threads)
local localsStr = locals and private.ParseLocals(locals, file) or ""
return file, line, func, localsStr, nil
end
function private.ParseLocals(locals, file)
if strmatch(file, "^%[") then
return
end
local fileName = strmatch(file, "([A-Za-z%-_0-9]+)%.lua")
local isBlizzardFile = strmatch(file, "Interface\\FrameXML\\")
local isPrivateTable, isLocaleTable, isPackageTable, isSelfTable = false, false, false, false
wipe(private.localLinesTemp)
locals = gsub(locals, "<([a-z]+)> {[\n\t ]+}", "<%1> {}")
locals = gsub(locals, " = <function> defined @", "@")
locals = gsub(locals, "<table> {", "{")
for localLine in gmatch(locals, "[^\n]+") do
local shouldIgnoreLine = false
if strmatch(localLine, "^ *%(") then
shouldIgnoreLine = true
elseif strmatch(localLine, "LibTSMClass%.lua:") then
-- ignore class methods
shouldIgnoreLine = true
elseif strmatch(localLine, "<unnamed> {}$") then
-- ignore internal WoW frame members
shouldIgnoreLine = true
end
if not shouldIgnoreLine then
local level = #strmatch(localLine, "^ *")
localLine = strrep(" ", level)..strtrim(localLine)
localLine = gsub(localLine, "Interface\\[Aa]dd[Oo]ns\\TradeSkillMaster", "TSM")
localLine = gsub(localLine, "\124", "\\124")
for matchStr, replaceStr in pairs(private.globalNameTranslation) do
localLine = gsub(localLine, matchStr, replaceStr)
end
if level > 0 then
if isBlizzardFile then
-- for Blizzard stack frames, only include level 0 locals
shouldIgnoreLine = true
elseif strmatch(localLine, "^ *[_]*[A-Z].+@TSM") then
-- ignore table methods (based on their name being UpperCamelCase - potentially with leading underscores)
shouldIgnoreLine = true
elseif isLocaleTable then
-- ignore everything within the locale table
shouldIgnoreLine = true
elseif isPackageTable then
-- ignore the package table completely
shouldIgnoreLine = true
elseif (isSelfTable or isPrivateTable) and strmatch(localLine, "^ *[_a-zA-Z0-9]+ = {}") then
-- ignore empty tables within objects or the private table
shouldIgnoreLine = true
elseif strmatch(localLine, "^%s+0 = <userdata>$") then
-- remove userdata table entries
shouldIgnoreLine = true
end
end
if not shouldIgnoreLine then
tinsert(private.localLinesTemp, localLine)
end
if level == 0 then
isPackageTable = strmatch(localLine, "%s*"..fileName.." = {") and true or false
isPrivateTable = strmatch(localLine, "%s*private = {") and true or false
isLocaleTable = strmatch(localLine, "%s*L = {") and true or false
isSelfTable = strmatch(localLine, "%s*self = {") and true or false
end
end
end
-- remove any top-level empty tables
local i = #private.localLinesTemp
while i > 0 do
if i > 1 and private.localLinesTemp[i] == "}" and strmatch(private.localLinesTemp[i - 1], "^[A-Za-z_].* = {$") then
tremove(private.localLinesTemp, i)
tremove(private.localLinesTemp, i - 1)
i = i - 2
elseif strmatch(private.localLinesTemp[i], "^[A-Za-z_].* = {}$") then
tremove(private.localLinesTemp, i)
i = i - 1
else
i = i - 1
end
end
return #private.localLinesTemp > 0 and table.concat(private.localLinesTemp, "\n") or nil
end
function private.IsTSMAddon(str)
if strfind(str, "Auc-Adcanced\\CoreScan.lua") then
-- ignore auctioneer errors
return nil
elseif strfind(str, "Master\\External\\") then
-- ignore errors from libraries
return nil
elseif strfind(str, "Master\\Core\\API.lua") then
-- ignore errors from public APIs
return nil
elseif strfind(str, "Master_AppHelper\\") then
return "TradeSkillMaster_AppHelper"
elseif strfind(str, "lMaster\\") then
return "TradeSkillMaster"
elseif strfind(str, "ster\\Core\\UI\\") then
return "TradeSkillMaster"
elseif strfind(str, "r\\LibTSM\\") then
return "TradeSkillMaster"
elseif strfind(str, "^TSM\\") then
return "TradeSkillMaster"
end
return nil
end
function private.AddonBlockedHandler(event, addonName, addonFunc)
if not strmatch(addonName, "TradeSkillMaster") then
return
end
-- just log it - it might not be TSM that cause the taint
Log.Err("[%s] AddOn '%s' tried to call the protected function '%s'.", event, addonName or "<name>", addonFunc or "<func>")
end
function private.SanitizeString(str)
str = gsub(str, "\124cff[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]([^\124]+)\124r", "%1")
str = gsub(str, "[\\]+", "/")
str = gsub(str, "\"", "'")
return str
end
function private.FormatErrorMessageSection(heading, info, isMultiLine)
-- replace unprintable characters with "?"
info = gsub(info, "[^\t\n -~]", "?")
local prefix = nil
if isMultiLine then
prefix = info ~= "" and "\n " or ""
info = gsub(info, "\n", "\n ")
else
prefix = info ~= "" and " " or ""
end
return "|cff99ffff"..heading..":|r"..prefix..info
end
-- ============================================================================
-- Error Frame
-- ============================================================================
function private.CreateErrorFrame()
if private.errorFrame then
return
end
local STEPS_TEXT = "Steps leading up to the error:\n1) List\n2) Steps\n3) Here"
local frame = CreateFrame("Frame", nil, UIParent, TSM.IsShadowlands() and "BackdropTemplate" or nil)
private.errorFrame = frame
frame:Hide()
frame:SetWidth(500)
frame:SetHeight(400)
frame:SetFrameStrata("FULLSCREEN_DIALOG")
frame:SetPoint("RIGHT", -100, 0)
frame:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8X8",
edgeFile = "Interface\\Buttons\\WHITE8X8",
edgeSize = 2,
})
frame:SetBackdropColor(0, 0, 0, 1)
frame:SetBackdropBorderColor(0.3, 0.3, 0.3, 1)
frame:SetScript("OnShow", function(self)
self.showingError = self.isManual or TSM.IsDevVersion()
self.details = STEPS_TEXT
if self.showingError then
-- this is a dev version so show the error (only)
self.text:SetText("Looks like TradeSkillMaster has encountered an error.")
self.switchBtn:SetText("Hide Error")
self.editBox:SetText(self.errorStr)
else
self.text:SetText("Looks like TradeSkillMaster has encountered an error. Please provide the steps which lead to this error to help the TSM team fix it, then click either button at the bottom of the window to automatically report this error.")
self.switchBtn:SetText("Show Error")
self.editBox:SetText(self.details)
end
end)
frame:SetScript("OnHide", function()
local details = private.errorFrame.showingError and private.errorFrame.details or private.errorFrame.editBox:GetText()
local changedDetails = details ~= STEPS_TEXT
if (not TSM.IsDevVersion() and not private.errorFrame.isManual and (changedDetails or private.num == 1)) or IsShiftKeyDown() then
tinsert(private.errorReports, {
errorInfo = private.errorFrame.errorInfo,
details = private.SanitizeString(details),
timestamp = time(),
})
end
private.errorSuppressed = nil
end)
local title = frame:CreateFontString()
title:SetHeight(20)
title:SetPoint("TOPLEFT", 0, -10)
title:SetPoint("TOPRIGHT", 0, -10)
title:SetFontObject(GameFontNormalLarge)
title:SetTextColor(1, 1, 1, 1)
title:SetJustifyH("CENTER")
title:SetJustifyV("MIDDLE")
local status, versionText = pcall(TSM.GetVersion)
versionText = status and versionText or "?"
title:SetText("TSM Error Window ("..versionText..")")
local hLine = frame:CreateTexture(nil, "ARTWORK")
hLine:SetHeight(2)
hLine:SetColorTexture(0.3, 0.3, 0.3, 1)
hLine:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -10)
hLine:SetPoint("TOPRIGHT", title, "BOTTOMRIGHT", 0, -10)
local text = frame:CreateFontString()
frame.text = text
text:SetHeight(45)
text:SetPoint("TOPLEFT", hLine, "BOTTOMLEFT", 8, -8)
text:SetPoint("TOPRIGHT", hLine, "BOTTOMRIGHT", -8, -8)
text:SetFontObject(GameFontNormal)
text:SetTextColor(1, 1, 1, 1)
text:SetJustifyH("LEFT")
text:SetJustifyV("MIDDLE")
local switchBtn = CreateFrame("Button", nil, frame)
frame.switchBtn = switchBtn
switchBtn:SetPoint("TOPRIGHT", -4, -10)
switchBtn:SetWidth(100)
switchBtn:SetHeight(20)
local fontString = switchBtn:CreateFontString()
fontString:SetFontObject(GameFontNormalSmall)
fontString:SetJustifyH("CENTER")
fontString:SetJustifyV("MIDDLE")
switchBtn:SetFontString(fontString)
switchBtn:SetScript("OnClick", function(self)
private.errorFrame.showingError = not private.errorFrame.showingError
if private.errorFrame.showingError then
private.errorFrame.details = private.errorFrame.editBox:GetText()
self:SetText("Hide Error")
private.errorFrame.editBox:SetText(private.errorFrame.errorStr)
else
self:SetText("Show Error")
private.errorFrame.editBox:SetText(private.errorFrame.details)
end
end)
local hLine2 = frame:CreateTexture(nil, "ARTWORK")
hLine2:SetHeight(2)
hLine2:SetColorTexture(0.3, 0.3, 0.3, 1)
hLine2:SetPoint("TOPLEFT", text, "BOTTOMLEFT", -8, -4)
hLine2:SetPoint("TOPRIGHT", text, "BOTTOMRIGHT", 8, -4)
local scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
scrollFrame:SetPoint("TOPLEFT", hLine2, "BOTTOMLEFT", 8, -4)
scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -26, 38)
local editBox = CreateFrame("EditBox", nil, scrollFrame)
frame.editBox = editBox
editBox:SetWidth(scrollFrame:GetWidth())
editBox:SetFontObject(ChatFontNormal)
editBox:SetMultiLine(true)
editBox:SetAutoFocus(false)
editBox:SetMaxLetters(0)
editBox:SetTextColor(1, 1, 1, 1)
editBox:SetScript("OnUpdate", function(self)
local offset = scrollFrame:GetVerticalScroll()
self:SetHitRectInsets(0, 0, offset, self:GetHeight() - offset - scrollFrame:GetHeight())
end)
editBox:SetScript("OnEditFocusGained", function(self)
self:HighlightText()
end)
editBox:SetScript("OnCursorChanged", function(self)
if private.errorFrame.showingError and self:HasFocus() then
self:HighlightText()
end
end)
editBox:SetScript("OnEscapePressed", function(self)
if private.errorFrame.showingError then
self:HighlightText(0, 0)
end
self:ClearFocus()
end)
scrollFrame:SetScrollChild(editBox)
local hLine3 = frame:CreateTexture(nil, "ARTWORK")
hLine3:SetHeight(2)
hLine3:SetColorTexture(0.3, 0.3, 0.3, 1)
hLine3:SetPoint("BOTTOMLEFT", frame, 0, 35)
hLine3:SetPoint("BOTTOMRIGHT", frame, 0, 35)
local reloadBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
frame.reloadBtn = reloadBtn
reloadBtn:SetPoint("BOTTOMLEFT", 4, 4)
reloadBtn:SetWidth(120)
reloadBtn:SetHeight(30)
reloadBtn:SetText(RELOADUI)
reloadBtn:SetScript("OnClick", function()
frame:Hide()
ReloadUI()
end)
local closeBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
frame.closeBtn = closeBtn
closeBtn:SetPoint("BOTTOMRIGHT", -4, 4)
closeBtn:SetWidth(120)
closeBtn:SetHeight(30)
closeBtn:SetText(DONE)
closeBtn:SetScript("OnClick", function()
frame:Hide()
end)
local stepsText = frame:CreateFontString()
frame.stepsText = stepsText
stepsText:SetWidth(200)
stepsText:SetHeight(30)
stepsText:SetPoint("BOTTOM", 0, 4)
stepsText:SetFontObject(GameFontNormal)
stepsText:SetTextColor(1, 0, 0, 1)
stepsText:SetJustifyH("CENTER")
stepsText:SetJustifyV("MIDDLE")
stepsText:SetText("Please enter steps before submitting")
end
-- ============================================================================
-- Register Error Handler
-- ============================================================================
do
private.origErrorHandler = geterrorhandler()
local function ErrorHandlerFunc(errMsg, isBugGrabber)
local tsmErrMsg = strtrim(tostring(errMsg))
if private.ignoreErrors then
-- we're ignoring errors
tsmErrMsg = nil
elseif strmatch(tsmErrMsg, "auc%-stat%-wowuction") or strmatch(tsmErrMsg, "TheUndermineJournal%.lua") or strmatch(tsmErrMsg, "\\SavedVariables\\TradeSkillMaster") or strmatch(tsmErrMsg, "AddOn TradeSkillMaster[_a-zA-Z]* attempted") or (strmatch(tsmErrMsg, "ItemTooltipClasses\\Wrapper%.lua:98") and strmatch(tsmErrMsg, "SetQuest")) then
-- explicitly ignore these errors
tsmErrMsg = nil
end
if tsmErrMsg then
-- look at the stack trace to see if this is a TSM error
for i = 2, MAX_STACK_DEPTH do
local stackLine = debugstack(i, 1, 0)
local oldModule = strmatch(stackLine, "(lMaster_[A-Za-z]+)")
if oldModule and tContains(OLD_TSM_MODULES, "TradeSkil"..oldModule) then
-- ignore errors from old modules
return
end
if not strmatch(stackLine, "^%[C%]:") and not strmatch(stackLine, "%(tail call%):") and not strmatch(stackLine, "^%[string \"[^@]") and not strmatch(stackLine, "lMaster\\External\\[A-Za-z0-9%-_%.]+\\") and not strmatch(stackLine, "SharedXML") and not strmatch(stackLine, "CallbackHandler") and not strmatch(stackLine, "!BugGrabber") and not strmatch(stackLine, "ErrorHandler%.lua") then
if not private.IsTSMAddon(stackLine) then
tsmErrMsg = nil
end
break
end
end
end
if tsmErrMsg then
local status, ret = pcall(private.ErrorHandler, tsmErrMsg)
if status and ret then
return ret
elseif not status and not private.hitInternalError then
private.hitInternalError = true
print("Internal TSM error: "..tostring(ret))
end
end
local oldModule = strmatch(errMsg, "(lMaster_[A-Za-z]+)")
if oldModule and tContains(OLD_TSM_MODULES, "TradeSkil"..oldModule) then
-- ignore errors from old modules
return
end
if not isBugGrabber then
return private.origErrorHandler and private.origErrorHandler(errMsg) or nil
end
end
seterrorhandler(ErrorHandlerFunc)
if BugGrabber and BugGrabber.RegisterCallback then
BugGrabber.RegisterCallback({}, "BugGrabber_BugGrabbed", function(_, errObj)
ErrorHandlerFunc(errObj.message, true)
end)
end
Event.Register("ADDON_ACTION_FORBIDDEN", private.AddonBlockedHandler)
Event.Register("ADDON_ACTION_BLOCKED", private.AddonBlockedHandler)
end

View File

@@ -0,0 +1,272 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local GuildTracking = TSM.Init("Service.GuildTracking")
local Database = TSM.Include("Util.Database")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local TempTable = TSM.Include("Util.TempTable")
local SlotId = TSM.Include("Util.SlotId")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local Settings = TSM.Include("Service.Settings")
local private = {
settings = nil,
slotDB = nil,
quantityDB = nil,
isOpen = nil,
pendingPetSlotIds = {},
}
local PLAYER_NAME = UnitName("player")
local PLAYER_GUILD = nil
local MAX_PET_SCANS = 10
-- don't use MAX_GUILDBANK_SLOTS_PER_TAB since it isn't available right away
local GUILD_BANK_TAB_SLOTS = 98
-- ============================================================================
-- Module Loading
-- ============================================================================
GuildTracking:OnSettingsLoad(function()
private.settings = Settings.NewView()
:AddKey("factionrealm", "internalData", "characterGuilds")
:AddKey("factionrealm", "internalData", "guildVaults")
private.slotDB = Database.NewSchema("GUILD_TRACKING_SLOTS")
:AddUniqueNumberField("slotId")
:AddNumberField("tab")
:AddNumberField("slot")
:AddStringField("itemString")
:AddSmartMapField("baseItemString", ItemString.GetBaseMap(), "itemString")
:AddNumberField("quantity")
:AddIndex("slotId")
:AddIndex("itemString")
:Commit()
private.quantityDB = Database.NewSchema("GUILD_TRACKING_QUANTITY")
:AddUniqueStringField("itemString")
:AddNumberField("quantity")
:Commit()
if not TSM.IsWowClassic() then
Event.Register("GUILDBANKFRAME_OPENED", private.GuildBankFrameOpenedHandler)
Event.Register("GUILDBANKFRAME_CLOSED", private.GuildBankFrameClosedHandler)
Event.Register("GUILDBANKBAGSLOTS_CHANGED", private.GuildBankBagSlotsChangedHandler)
Delay.AfterFrame(1, private.GetGuildName)
Event.Register("PLAYER_GUILD_UPDATE", private.GetGuildName)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function GuildTracking.BaseItemIterator()
return private.quantityDB:NewQuery()
:Select("itemString")
:IteratorAndRelease()
end
function GuildTracking.CreateQuery()
return private.slotDB:NewQuery()
end
function GuildTracking.CreateQueryItem(itemString)
local query = GuildTracking.CreateQuery()
if itemString == ItemString.GetBaseFast(itemString) then
query:Equal("baseItemString", itemString)
else
query:Equal("itemString", itemString)
end
return query
end
function GuildTracking.GetQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "quantity") or 0
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetGuildName()
if not IsInGuild() then
private.settings.characterGuilds[PLAYER_NAME] = nil
return
end
PLAYER_GUILD = GetGuildInfo("player")
if not PLAYER_GUILD then
-- try again next frame
Delay.AfterFrame(1, private.GetGuildName)
return
end
private.settings.characterGuilds[PLAYER_NAME] = PLAYER_GUILD
-- clean up any guilds with no players in them
local validGuilds = TempTable.Acquire()
for _, character in Settings.CharacterByAccountFactionrealmIterator() do
local guild = private.settings.characterGuilds[character]
if guild then
validGuilds[guild] = true
end
end
for character, guild in pairs(private.settings.characterGuilds) do
if not validGuilds[guild] then
private.settings.characterGuilds[character] = nil
end
end
for guild in pairs(private.settings.guildVaults) do
if not validGuilds[guild] then
private.settings.guildVaults[guild] = nil
end
end
TempTable.Release(validGuilds)
private.settings.guildVaults[PLAYER_GUILD] = private.settings.guildVaults[PLAYER_GUILD] or {}
for itemString, quantity in pairs(private.settings.guildVaults[PLAYER_GUILD]) do
if quantity <= 0 or itemString ~= ItemString.GetBase(itemString) then
private.settings.guildVaults[PLAYER_GUILD][itemString] = nil
end
end
private.RebuildQuantityDB()
end
function private.RebuildQuantityDB()
private.quantityDB:TruncateAndBulkInsertStart()
for itemString, quantity in pairs(private.settings.guildVaults[PLAYER_GUILD]) do
if quantity > 0 then
private.quantityDB:BulkInsertNewRow(itemString, quantity)
else
private.settings.guildVaults[PLAYER_GUILD][itemString] = nil
end
end
private.quantityDB:BulkInsertEnd()
end
function private.GuildBankFrameOpenedHandler()
local initialTab = GetCurrentGuildBankTab()
for i = 1, GetNumGuildBankTabs() do
QueryGuildBankTab(i)
end
QueryGuildBankTab(initialTab)
private.isOpen = true
end
function private.GuildBankFrameClosedHandler()
private.isOpen = nil
end
function private.GuildBankBagSlotsChangedHandler()
Delay.AfterFrame("guildBankScan", 2, private.GuildBankChangedDelayed)
end
function private.GuildBankChangedDelayed()
if not private.isOpen then
return
end
if not PLAYER_GUILD then
-- we don't have the guild name yet, so try again after a short delay
Delay.AfterFrame("guildBankScan", 2, private.GuildBankChangedDelayed)
return
end
private.ScanGuildBank()
end
function private.ScanGuildBank()
wipe(private.settings.guildVaults[PLAYER_GUILD])
wipe(private.pendingPetSlotIds)
private.slotDB:TruncateAndBulkInsertStart()
local didFail = false
for tab = 1, GetNumGuildBankTabs() do
-- only scan tabs which we have at least enough withdrawals to withdraw every slot
local _, _, _, _, numWithdrawals = GetGuildBankTabInfo(tab)
if numWithdrawals == -1 or numWithdrawals >= GUILD_BANK_TAB_SLOTS then
for slot = 1, GUILD_BANK_TAB_SLOTS do
local itemLink = GetGuildBankItemLink(tab, slot)
if itemLink then
local slotId = SlotId.Join(tab, slot)
local baseItemString = ItemString.GetBase(itemLink)
if baseItemString == ItemString.GetPetCage() then
private.pendingPetSlotIds[slotId] = true
baseItemString = nil
end
if baseItemString then
local _, quantity = GetGuildBankItemInfo(tab, slot)
if quantity == 0 then
-- the info for this slot isn't fully loaded yet
Log.Err("Failed to scan guild bank slot (%d)", slotId)
didFail = true
break
end
private.settings.guildVaults[PLAYER_GUILD][baseItemString] = (private.settings.guildVaults[PLAYER_GUILD][baseItemString] or 0) + quantity
local itemString = ItemString.Get(itemLink)
private.slotDB:BulkInsertNewRow(slotId, tab, slot, itemString, quantity)
end
end
end
end
if didFail then
break
end
end
private.RebuildQuantityDB()
private.slotDB:BulkInsertEnd()
if didFail then
Delay.AfterFrame("guildBankScan", 2, private.GuildBankChangedDelayed)
elseif next(private.pendingPetSlotIds) then
Delay.AfterFrame("guildBankPetScan", 2, private.ScanPetsDeferred)
else
Delay.Cancel("guildBankPetScan")
end
end
function private.ScanPetsDeferred()
if not TSMScanTooltip then
CreateFrame("GameTooltip", "TSMScanTooltip", UIParent, "GameTooltipTemplate")
end
TSMScanTooltip:SetOwner(UIParent, "ANCHOR_NONE")
TSMScanTooltip:ClearLines()
local numPetSlotIdsScanned = 0
local toRemove = TempTable.Acquire()
private.slotDB:BulkInsertStart()
for slotId in pairs(private.pendingPetSlotIds) do
local tab, slot = SlotId.Split(slotId)
local speciesId, level, rarity = TSMScanTooltip:SetGuildBankItem(tab, slot)
if speciesId and level and rarity then
local itemString = "p:"..speciesId..":"..level..":"..rarity
if itemString then
tinsert(toRemove, slotId)
local _, quantity = GetGuildBankItemInfo(tab, slot)
local baseItemString = ItemString.GetBase(itemString)
private.settings.guildVaults[PLAYER_GUILD][baseItemString] = (private.settings.guildVaults[PLAYER_GUILD][baseItemString] or 0) + quantity
private.slotDB:BulkInsertNewRow(slotId, tab, slot, itemString, quantity)
end
end
-- throttle how many pet slots we scan per call (regardless of whether or not it was successful)
numPetSlotIdsScanned = numPetSlotIdsScanned + 1
if numPetSlotIdsScanned == MAX_PET_SCANS then
break
end
end
private.RebuildQuantityDB()
private.slotDB:BulkInsertEnd()
Log.Info("Scanned %d pet slots", numPetSlotIdsScanned)
for _, slotId in ipairs(toRemove) do
private.pendingPetSlotIds[slotId] = nil
end
TempTable.Release(toRemove)
if next(private.pendingPetSlotIds) then
-- there are more to scan
Delay.AfterFrame("guildBankPetScan", 2, private.ScanPetsDeferred)
end
end

View File

@@ -0,0 +1,149 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Inventory = TSM.Init("Service.Inventory")
local ItemString = TSM.Include("Util.ItemString")
local Settings = TSM.Include("Service.Settings")
local CustomPrice = TSM.Include("Service.CustomPrice")
local BagTracking = TSM.Include("Service.BagTracking")
local AuctionTracking = TSM.Include("Service.AuctionTracking")
local MailTracking = TSM.Include("Service.MailTracking")
local Sync = TSM.Include("Service.Sync")
local private = {
settings = nil,
callbacks = {},
}
local PLAYER_NAME = UnitName("player")
-- ============================================================================
-- Module Loading
-- ============================================================================
Inventory:OnSettingsLoad(function()
private.settings = Settings.NewView()
:AddKey("factionrealm", "internalData", "pendingMail")
:AddKey("factionrealm", "coreOptions", "ignoreGuilds")
:AddKey("factionrealm", "internalData", "guildVaults")
BagTracking.RegisterCallback(private.QuantityChangedCallback)
AuctionTracking.RegisterCallback(private.QuantityChangedCallback)
MailTracking.RegisterCallback(private.QuantityChangedCallback)
Sync.RegisterMirrorCallback(private.QuantityChangedCallback)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Inventory.RegisterCallback(callback)
tinsert(private.callbacks, callback)
end
function Inventory.GetBagQuantity(itemString, character, factionrealm)
return private.InventoryQuantityHelper(itemString, "bagQuantity", character, factionrealm)
end
function Inventory.GetBankQuantity(itemString, character, factionrealm)
return private.InventoryQuantityHelper(itemString, "bankQuantity", character, factionrealm)
end
function Inventory.GetReagentBankQuantity(itemString, character, factionrealm)
return private.InventoryQuantityHelper(itemString, "reagentBankQuantity", character, factionrealm)
end
function Inventory.GetAuctionQuantity(itemString, character, factionrealm)
return private.InventoryQuantityHelper(itemString, "auctionQuantity", character, factionrealm)
end
function Inventory.GetMailQuantity(itemString, character, factionrealm)
itemString = ItemString.GetBaseFast(itemString)
character = character or PLAYER_NAME
local pendingQuantity = itemString and private.settings.pendingMail[character] and private.settings.pendingMail[character][itemString] or 0
return private.InventoryQuantityHelper(itemString, "mailQuantity", character, factionrealm) + pendingQuantity
end
function Inventory.GetGuildQuantity(itemString, guild)
itemString = ItemString.GetBase(itemString)
if not itemString then
return 0
end
guild = guild or (IsInGuild() and GetGuildInfo("player") or nil)
if not guild or private.settings.ignoreGuilds[guild] then
return 0
end
return private.settings.guildVaults[guild] and private.settings.guildVaults[guild][itemString] or 0
end
function Inventory.GetPlayerTotals(itemString)
itemString = ItemString.GetBaseFast(itemString)
local numPlayer, numAlts, numAuctions, numAltAuctions = 0, 0, 0, 0
numPlayer = numPlayer + Inventory.GetBagQuantity(itemString)
numPlayer = numPlayer + Inventory.GetBankQuantity(itemString)
numPlayer = numPlayer + Inventory.GetReagentBankQuantity(itemString)
numPlayer = numPlayer + Inventory.GetMailQuantity(itemString)
numAuctions = numAuctions + Inventory.GetAuctionQuantity(itemString)
for _, factionrealm, character in Settings.ConnectedFactionrealmAltCharacterIterator() do
numAlts = numAlts + Inventory.GetBagQuantity(itemString, character, factionrealm)
numAlts = numAlts + Inventory.GetBankQuantity(itemString, character, factionrealm)
numAlts = numAlts + Inventory.GetReagentBankQuantity(itemString, character, factionrealm)
numAlts = numAlts + Inventory.GetMailQuantity(itemString, character, factionrealm)
local auctionQuantity = Inventory.GetAuctionQuantity(itemString, character, factionrealm)
numAltAuctions = numAltAuctions + auctionQuantity
numAuctions = numAuctions + auctionQuantity
end
return numPlayer, numAlts, numAuctions, numAltAuctions
end
function Inventory.GetGuildTotal(itemString)
itemString = ItemString.GetBaseFast(itemString)
if not itemString then
return 0
end
local numGuild = 0
for guild, data in pairs(private.settings.guildVaults) do
if not private.settings.ignoreGuilds[guild] then
numGuild = numGuild + (data[itemString] or 0)
end
end
return numGuild
end
function Inventory.GetTotalQuantity(itemString)
local numPlayer, numAlts, numAuctions = Inventory.GetPlayerTotals(itemString)
local numGuild = Inventory.GetGuildTotal(itemString)
return numPlayer + numAlts + numAuctions + numGuild
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetCharacterInventoryData(settingKey, character, factionrealm)
local scopeKey = character and Settings.GetSyncScopeKeyByCharacter(character, factionrealm) or nil
return Settings.Get("sync", scopeKey, "internalData", settingKey)
end
function private.InventoryQuantityHelper(itemString, settingKey, character, factionrealm)
itemString = ItemString.GetBase(itemString)
if not itemString then
return 0
end
local tbl = private.GetCharacterInventoryData(settingKey, character, factionrealm)
return tbl and tbl[itemString] or 0
end
function private.QuantityChangedCallback()
CustomPrice.OnSourceChange("NumInventory")
for _, callback in ipairs(private.callbacks) do
callback()
end
end

View File

@@ -0,0 +1,232 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local InventoryInfo = TSM.Init("Service.InventoryInfo")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Event = TSM.Include("Util.Event")
local SlotId = TSM.Include("Util.SlotId")
local ItemString = TSM.Include("Util.ItemString")
local Table = TSM.Include("Util.Table")
local private = {
slotIdLocked = {},
slotIdSoulboundCached = {},
slotIdIsBoP = {},
slotIdIsBoA = {},
}
-- ============================================================================
-- Module Loading
-- ============================================================================
InventoryInfo:OnModuleLoad(function()
Event.Register("ITEM_LOCKED", private.ItemLockedHandler)
Event.Register("ITEM_UNLOCKED", private.ItemUnlockedHandler)
Event.Register("BAG_UPDATE", private.BagUpdateHandler)
Event.Register("PLAYERBANKSLOTS_CHANGED", private.BankSlotChangedHandler)
if not TSM.IsWowClassic() then
Event.Register("PLAYERREAGENTBANKSLOTS_CHANGED", private.ReagentBankSlotChangedHandler)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Check if an item will go in a bag.
-- @tparam string link The item
-- @tparam number bag The bag index
-- @treturn boolean Whether or not the item will go in the bag
function InventoryInfo.ItemWillGoInBag(link, bag)
if not link or not bag then
return
end
if bag == BACKPACK_CONTAINER or bag == BANK_CONTAINER then
return true
elseif bag == REAGENTBANK_CONTAINER then
return IsReagentBankUnlocked() and ItemInfo.IsCraftingReagent(link)
end
local itemFamily = GetItemFamily(link) or 0
if ItemInfo.GetClassId(link) == LE_ITEM_CLASS_CONTAINER then
-- bags report their family as what can go inside them, not what they can go inside
itemFamily = 0
end
local _, bagFamily = GetContainerNumFreeSlots(bag)
if not bagFamily then
return
end
return bagFamily == 0 or bit.band(itemFamily, bagFamily) > 0
end
function InventoryInfo.IsBagSlotLocked(bag, slot)
return private.slotIdLocked[SlotId.Join(bag, slot)]
end
function InventoryInfo.IsSoulbound(bag, slot)
local slotId = SlotId.Join(bag, slot)
if private.slotIdSoulboundCached[slotId] then
return private.slotIdIsBoP[slotId], private.slotIdIsBoA[slotId]
end
if not TSMScanTooltip then
CreateFrame("GameTooltip", "TSMScanTooltip", UIParent, "GameTooltipTemplate")
end
TSMScanTooltip:SetOwner(UIParent, "ANCHOR_NONE")
TSMScanTooltip:ClearLines()
if GetContainerItemID(bag, slot) == ItemString.ToId(ItemString.GetPetCage()) then
-- battle pets are never BoP or BoA
private.slotIdSoulboundCached[slotId] = true
private.slotIdIsBoP[slotId] = false
private.slotIdIsBoA[slotId] = false
return false, false
end
-- set TSMScanTooltip to show the inventory item
if bag == BANK_CONTAINER then
TSMScanTooltip:SetInventoryItem("player", BankButtonIDToInvSlotID(slot))
elseif bag == REAGENTBANK_CONTAINER then
TSMScanTooltip:SetInventoryItem("player", ReagentBankButtonIDToInvSlotID(slot))
else
TSMScanTooltip:SetBagItem(bag, slot)
end
-- scan the tooltip
local numLines = TSMScanTooltip:NumLines()
if numLines < 1 then
-- the tooltip didn't fully load or there's nothing in this slot
return nil, nil
end
local isBOP, isBOA = false, false
for id = 2, numLines do
local text = private.GetTooltipText(_G["TSMScanTooltipTextLeft"..id])
if text then
if (text == ITEM_BIND_ON_PICKUP and id < 4) or text == ITEM_SOULBOUND or text == ITEM_BIND_QUEST then
isBOP = true
break
elseif (text == ITEM_ACCOUNTBOUND or text == ITEM_BIND_TO_ACCOUNT or text == ITEM_BIND_TO_BNETACCOUNT or text == ITEM_BNETACCOUNTBOUND) then
isBOA = true
break
end
end
end
private.slotIdSoulboundCached[slotId] = true
private.slotIdIsBoP[slotId] = isBOP
private.slotIdIsBoA[slotId] = isBOA
return isBOP, isBOA
end
function InventoryInfo.HasUsedCharges(bag, slot)
-- figure out if this item has a max number of charges
local itemId = GetContainerItemID(bag, slot)
if not itemId or itemId == ItemString.ToId(ItemString.GetPetCage()) then
return false
end
if not TSMScanTooltip then
CreateFrame("GameTooltip", "TSMScanTooltip", UIParent, "GameTooltipTemplate")
end
TSMScanTooltip:SetOwner(UIParent, "ANCHOR_NONE")
TSMScanTooltip:ClearLines()
TSMScanTooltip:SetItemByID(itemId)
local maxCharges = private.GetScanTooltipCharges()
if not maxCharges then
return false
end
-- set TSMScanTooltip to show the inventory item
if bag == BANK_CONTAINER then
TSMScanTooltip:SetInventoryItem("player", BankButtonIDToInvSlotID(slot))
elseif bag == REAGENTBANK_CONTAINER then
TSMScanTooltip:SetInventoryItem("player", ReagentBankButtonIDToInvSlotID(slot))
else
TSMScanTooltip:SetBagItem(bag, slot)
end
-- check if there are used charges
if maxCharges and private.GetScanTooltipCharges() ~= maxCharges then
return true
end
return false
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ItemLockedHandler(_, bag, slot)
if not slot then
return
end
private.slotIdLocked[SlotId.Join(bag, slot)] = true
end
function private.ItemUnlockedHandler(_, bag, slot)
if not slot then
return
end
private.slotIdLocked[SlotId.Join(bag, slot)] = nil
end
function private.BagUpdateHandler(_, bag)
-- clear the soulbound cache for everything in this bag
Table.Filter(private.slotIdSoulboundCached, private.SlotIdSoulboundCachedFilter, bag)
end
function private.BankSlotChangedHandler(_, slot)
if slot <= NUM_BANKGENERIC_SLOTS then
-- one of the slots of the primary bank container changed, so just clear the cache for this slot
private.slotIdSoulboundCached[SlotId.Join(BANK_CONTAINER, slot)] = nil
else
-- one of the extra bank bags changed, so clear the cache for the entire bag
Table.Filter(private.slotIdSoulboundCached, private.SlotIdSoulboundCachedFilter, slot - NUM_BANKGENERIC_SLOTS)
end
end
function private.SlotIdSoulboundCachedFilter(slotId, _, bag)
return SlotId.Split(slotId) == bag
end
function private.ReagentBankSlotChangedHandler(_, slot)
-- clear the soulbound cache for this slot
private.slotIdSoulboundCached[SlotId.Join(REAGENTBANK_CONTAINER, slot)] = nil
end
function private.GetTooltipText(text)
local textStr = strtrim(text and text:GetText() or "")
if textStr == "" then return end
local r, g, b = text:GetTextColor()
return textStr, floor(r * 256), floor(g * 256), floor(b * 256)
end
function private.GetScanTooltipCharges()
for id = 2, TSMScanTooltip:NumLines() do
local text = private.GetTooltipText(_G["TSMScanTooltipTextLeft"..id])
local num = text and strmatch(text, "%d+")
local chargesStr = gsub(ITEM_SPELL_CHARGES, "%%d", "%%d+")
if strfind(chargesStr, ":") then
if num == 1 then
chargesStr = gsub(chargesStr, "\1244(.+):.+;", "%1")
else
chargesStr = gsub(chargesStr, "\1244.+:(.+);", "%1")
end
end
local maxCharges = text and strmatch(text, "^"..chargesStr.."$")
if maxCharges then
return maxCharges
end
end
end

View File

@@ -0,0 +1,436 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ItemFilter = TSM.Init("Service.ItemFilter")
local LibTSMClass = TSM.Include("LibTSMClass")
local L = TSM.Include("Locale").GetTable()
local Filter = LibTSMClass.DefineClass("ItemFilter")
local ItemClass = TSM.Include("Data.ItemClass")
local Money = TSM.Include("Util.Money")
local String = TSM.Include("Util.String")
local Vararg = TSM.Include("Util.Vararg")
local Log = TSM.Include("Util.Log")
local ItemInfo = TSM.Include("Service.ItemInfo")
-- ============================================================================
-- Module Functions
-- ============================================================================
function ItemFilter.New()
return Filter()
end
-- ============================================================================
-- Filter Class
-- ============================================================================
function Filter.__init(self)
self._isValid = nil
self._str = nil
self._escapedStr = nil
self._class = nil
self._subClass = nil
self._invSlotId = nil
self._minQuality = nil
self._maxQuality = nil
self._minLevel = nil
self._maxLevel = nil
self._minItemLevel = nil
self._maxItemLevel = nil
self._minPrice = nil
self._maxPrice = nil
self._maxQuantity = nil
self._uncollected = nil
self._usable = nil
self._upgrades = nil
self._unlearned = nil
self._canlearn = nil
self._exactOnly = nil
self._crafting = nil
self._disenchant = nil
self._item = nil
self:_Reset()
end
function Filter._Reset(self)
self._isValid = nil
self._str = ""
self._escapedStr = ""
self._class = nil
self._subClass = nil
self._invSlotId = nil
self._minQuality = -math.huge
self._maxQuality = math.huge
self._minLevel = 0
self._maxLevel = math.huge
self._minItemLevel = 0
self._maxItemLevel = math.huge
self._minPrice = 0
self._maxPrice = math.huge
self._maxQuantity = math.huge
self._uncollected = nil
self._usable = nil
self._upgrades = nil
self._unlearned = nil
self._canlearn = nil
self._exactOnly = nil
self._crafting = nil
self._disenchant = nil
self._item = nil
end
function Filter._ItemQualityToIndex(self, str)
for i = 0, 7 do
local text = _G["ITEM_QUALITY"..i.."_DESC"]
if strlower(str) == strlower(text) then
return i
end
end
end
function Filter.ParseStr(self, str)
self:_Reset()
local errMsg = nil
local numLevelParts, numItemLevelParts, numPriceParts, numQualityParts = 0, 0, 0, 0
self._isValid = nil
local hasNonCraftingPart = false
for i, part in Vararg.Iterator(strsplit("/", strtrim(str))) do
part = strtrim(part)
if self._isValid ~= nil then
-- already done iterating, but can't break / return out of a VarargIterator
if strmatch(part, "^[ip]:[0-9]+") then
-- request item info in case we failed due to not having it (for next time)
ItemInfo.FetchInfo(part)
end
elseif i == 1 then
-- first part must be a filter string or an item
if strmatch(part, "^[ip]:[0-9]+") then
local name = ItemInfo.GetName(part)
local level = ItemInfo.GetMinLevel(part)
local quality = ItemInfo.GetQuality(part)
if not name or not level or not quality then
errMsg = L["The specified item was not found."]
self._isValid = false
else
self._exactOnly = true
self._item = part
self._str = strlower(name)
self._escapedStr = String.Escape(self._str)
self._minQuality = quality
self._maxQuality = quality
self._minLevel = level
self._maxLevel = level
self._class = ItemInfo.GetClassId(self._item) or 0
self._subClass = ItemInfo.GetSubClassId(self._item) or 0
end
else
self._str = strlower(part)
self._escapedStr = String.Escape(self._str)
end
elseif part == "" then
-- ignore an empty part
elseif tonumber(part) then
if numLevelParts == 0 then
self._minLevel = tonumber(part)
elseif numLevelParts == 1 then
self._maxLevel = tonumber(part)
else
-- already have min / max level
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
numLevelParts = numLevelParts + 1
hasNonCraftingPart = true
elseif tonumber(strmatch(part, "^i(%d+)$")) then
if numItemLevelParts == 0 then
self._minItemLevel = tonumber(strmatch(part, "^i(%d+)$"))
elseif numItemLevelParts == 1 then
self._maxItemLevel = tonumber(strmatch(part, "^i(%d+)$"))
else
-- already have min / max item level
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
numItemLevelParts = numItemLevelParts + 1
hasNonCraftingPart = true
elseif ItemClass.GetClassIdFromClassString(part) then
self._class = ItemClass.GetClassIdFromClassString(part)
hasNonCraftingPart = true
elseif self._class and ItemClass.GetSubClassIdFromSubClassString(part, self._class) then
self._subClass = ItemClass.GetSubClassIdFromSubClassString(part, self._class)
hasNonCraftingPart = true
elseif ItemClass.GetInventorySlotIdFromInventorySlotString(part) then
self._invSlotId = ItemClass.GetInventorySlotIdFromInventorySlotString(part)
hasNonCraftingPart = true
elseif self:_ItemQualityToIndex(part) then
if numQualityParts == 0 then
self._minQuality = self:_ItemQualityToIndex(part)
elseif numQualityParts == 1 then
self._maxQuality = self:_ItemQualityToIndex(part)
else
-- already have min / max quality
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
numQualityParts = numQualityParts + 1
hasNonCraftingPart = true
elseif Money.FromString(part) then
if numPriceParts == 0 then
self._maxPrice = Money.FromString(part)
elseif numPriceParts == 1 then
self._minPrice = self._maxPrice
self._maxPrice = Money.FromString(part)
else
-- already have min / max price
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
numPriceParts = numPriceParts + 1
hasNonCraftingPart = true
elseif not TSM.IsWowClassic() and strlower(part) == "uncollected" then
if self._uncollected then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._uncollected = true
hasNonCraftingPart = true
elseif strlower(part) == "usable" then
if self._usable then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._usable = true
hasNonCraftingPart = true
elseif not TSM.IsWowClassic() and strlower(part) == "upgrades" then
if self._upgrades then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._upgrades = true
hasNonCraftingPart = true
elseif strlower(part) == "unlearned" then
if self._unlearned then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
if CanIMogIt and CanIMogIt.PlayerKnowsTransmog then
self._unlearned = true
else
Log.PrintUser(L["The unlearned filter was ignored because the CanIMogIt addon was not found."])
end
hasNonCraftingPart = true
elseif strlower(part) == "canlearn" then
if self._canlearn then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
if CanIMogIt and CanIMogIt.CharacterCanLearnTransmog then
self._canlearn = true
else
Log.PrintUser(L["The canlearn filter was ignored because the CanIMogIt addon was not found."])
end
hasNonCraftingPart = true
elseif strlower(part) == "exact" then
if self._exactOnly then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._exactOnly = true
hasNonCraftingPart = true
elseif tonumber(strmatch(part, "^x(%d+)$")) then
self._maxQuantity = tonumber(strmatch(part, "^x(%d+)$"))
if self._maxQuantity == 0 then
errMsg = L["The max quantity cannot be zero."]
self._isValid = false
end
elseif strlower(part) == "crafting" then
if self._crafting or self._disenchant then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._crafting = true
elseif strlower(part) == "disenchant" then
if self._disenchant or self._crafting then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._disenchant = true
else
-- invalid part
errMsg = format(L["Unknown word (%s)."], part)
self._isValid = false
end
end
if (self._crafting or self._disenchant) and hasNonCraftingPart then
errMsg = L["Cannot use additional filters with /crafting or /disenchant."]
self._isValid = false
end
if self._isValid == nil then
self._isValid = true
end
return self._isValid, errMsg
end
function Filter.GetStr(self)
return self._str ~= "" and self._str or nil
end
function Filter.GetItem(self)
return self._item
end
function Filter.GetMinQuality(self)
return self._minQuality ~= -math.huge and self._minQuality or nil
end
function Filter.GetMaxQuality(self)
return self._maxQuality ~= math.huge and self._maxQuality or nil
end
function Filter.GetClass(self)
return self._class
end
function Filter.GetSubClass(self)
return self._subClass
end
function Filter.GetInvSlotId(self)
return self._invSlotId
end
function Filter.GetMinLevel(self)
return self._minLevel ~= 0 and self._minLevel or nil
end
function Filter.GetMaxLevel(self)
return self._maxLevel ~= math.huge and self._maxLevel or nil
end
function Filter.GetMinItemLevel(self)
return self._minItemLevel ~= 0 and self._minItemLevel or nil
end
function Filter.GetMaxItemLevel(self)
return self._maxItemLevel ~= math.huge and self._maxItemLevel or nil
end
function Filter.GetUncollected(self)
return self._uncollected
end
function Filter.GetUsableOnly(self)
return self._usable
end
function Filter.GetUpgrades(self)
return self._upgrades
end
function Filter.GetUnlearned(self)
return self._unlearned
end
function Filter.GetCanLearn(self)
return self._canlearn
end
function Filter.GetExactOnly(self)
return self._exactOnly
end
function Filter.GetMaxQuantity(self)
return self._maxQuantity ~= math.huge and self._maxQuantity or nil
end
function Filter.GetMinPrice(self)
return self._minPrice ~= 0 and self._minPrice or nil
end
function Filter.GetMaxPrice(self)
return self._maxPrice ~= math.huge and self._maxPrice or nil
end
function Filter.GetCrafting(self)
return self._crafting
end
function Filter.GetDisenchant(self)
return self._disenchant
end
function Filter.Matches(self, item, price)
if not self._isValid then
return false
end
-- check the name
local name = ItemInfo.GetName(item)
name = name and strlower(name)
if not name or not strfind(name, self._escapedStr) or (self._exactOnly and name ~= self._str) then
return false
end
-- check the quality
local quality = ItemInfo.GetQuality(item)
if not quality or quality < self._minQuality or quality > self._maxQuality then
return false
end
-- check the item level
local itemLevel = ItemInfo.GetItemLevel(item)
if not itemLevel or itemLevel < self._minItemLevel or itemLevel > self._maxItemLevel then
return false
end
-- check the required level
local level = ItemInfo.GetMinLevel(item)
if not level or level < self._minLevel or level > self._maxLevel then
return false
end
-- check the item class
if self._class and ItemInfo.GetClassId(item) ~= self._class then
return false
end
-- check the item subclass
if self._subClass and ItemInfo.GetSubClassId(item) ~= self._subClass then
return false
end
-- check the inventory slot
if self._invSlotId and ItemInfo.GetInvSlotId(item) ~= self._invSlotId then
return false
end
-- check unlearned
if self._unlearned and CanIMogIt:PlayerKnowsTransmog(ItemInfo.GetLink(item)) then
return false
end
-- check canlearn
if self._canlearn and CanIMogIt:CharacterCanLearnTransmog(ItemInfo.GetLink(item)) then
return false
end
-- check the price
price = price or 0
if price < self._minPrice or price > self._maxPrice then
return false
end
-- it passed!
return true
end

1368
LibTSM/Service/ItemInfo.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- ItemLinked Functions.
-- @module ItemLinked
local _, TSM = ...
local ItemLinked = TSM.Init("Service.ItemLinked")
local Table = TSM.Include("Util.Table")
local ItemInfo = TSM.Include("Service.ItemInfo")
local private = {
callbacks = {},
priorityLookup = {}
}
-- ============================================================================
-- Module Loading
-- ============================================================================
ItemLinked:OnModuleLoad(function()
local origHandleModifiedItemClick = HandleModifiedItemClick
HandleModifiedItemClick = function(link)
return private.ItemLinkedHook(origHandleModifiedItemClick, link)
end
local origChatEdit_InsertLink = ChatEdit_InsertLink
ChatEdit_InsertLink = function(link)
return private.ItemLinkedHook(origChatEdit_InsertLink, link)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function ItemLinked.RegisterCallback(callback, priority)
assert(type(callback) == "function")
tinsert(private.callbacks, callback)
private.priorityLookup[callback] = (priority or 0) + #private.callbacks * 0.01
Table.SortWithValueLookup(private.callbacks, private.priorityLookup)
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ItemLinkedHook(origFunc, itemLink)
local putIntoChat = origFunc(itemLink)
if putIntoChat then
return putIntoChat
end
local name = ItemInfo.GetName(itemLink)
if not name or not private.HandleItemLinked(name, itemLink) then
return putIntoChat
end
return true
end
function private.HandleItemLinked(name, itemLink)
for _, callback in ipairs(private.callbacks) do
if callback(name, itemLink) then
return true
end
end
end

View File

@@ -0,0 +1,24 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ItemTooltip = TSM.Init("Service.ItemTooltip")
local Builder = TSM.Include("Service.ItemTooltipClasses.Builder")
local Wrapper = TSM.Include("Service.ItemTooltipClasses.Wrapper")
-- ============================================================================
-- Module Functions
-- ============================================================================
function ItemTooltip.CreateBuilder()
return Builder.Create()
end
function ItemTooltip.SetWrapperPopulateFunction(func)
Wrapper.SetPopulateFunction(func)
end

View File

@@ -0,0 +1,270 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Builder = TSM.Init("Service.ItemTooltipClasses.Builder")
local L = TSM.Include("Locale").GetTable()
local TempTable = TSM.Include("Util.TempTable")
local Math = TSM.Include("Util.Math")
local Money = TSM.Include("Util.Money")
local ItemString = TSM.Include("Util.ItemString")
local Theme = TSM.Include("Util.Theme")
local ItemInfo = TSM.Include("Service.ItemInfo")
local LibTSMClass = TSM.Include("LibTSMClass")
local TooltipBuilder = LibTSMClass.DefineClass("TooltipBuilder")
local private = {}
local TOOLTIP_CACHE_TIME = 5
local LINE_PART_SEP = "\t"
local DISABLED_TINT = -30
-- ============================================================================
-- TooltipBuilder - Class Meta Methods
-- ============================================================================
function Builder.Create()
return TooltipBuilder()
end
-- ============================================================================
-- TooltipBuilder - Class Meta Methods
-- ============================================================================
function TooltipBuilder.__init(self)
self._lines = {}
self._lineColors = {}
self._level = 0
self._levelNumLines = {}
self._levelHasHeading = {}
self._lastUpdate = 0
self._modifier = nil
self._inCombat = false
self._itemString = nil
self._quantity = nil
self._disabled = nil
end
-- ============================================================================
-- TooltipBuilder - Public Methods
-- ============================================================================
function TooltipBuilder.ApplyValueColor(self, text)
local color = Theme.GetColor("TEXT")
if self._disabled then
color = color:GetTint(DISABLED_TINT)
end
return color:ColorText(text)
end
function TooltipBuilder.FormatMoney(self, value, color)
color = color or Theme.GetColor("TEXT")
if self._disabled then
color = color:GetTint(DISABLED_TINT)
end
if not value then
return self:ApplyValueColor("---")
end
return Money.ToString(value, color:GetTextColorPrefix(), TSM.db.global.tooltipOptions.tooltipPriceFormat == "icon" and "OPT_ICON" or nil, self._disabled and "OPT_DISABLE" or nil)
end
function TooltipBuilder.AddLine(self, lineLeft, lineRight, color)
if lineRight then
lineLeft = strjoin(LINE_PART_SEP, lineLeft, lineRight)
end
local line = strrep(" ", self._level)..lineLeft
color = color or Theme.GetColor("INDICATOR_ALT")
if self._disabled then
color = color:GetTint(DISABLED_TINT)
end
assert(not self._lineColors[line] or self._lineColors[line] == color)
self._lineColors[line] = color
tinsert(self._lines, line)
self._levelNumLines[self._level] = self._levelNumLines[self._level] + 1
end
function TooltipBuilder.AddTextLine(self, label, text, ...)
if select("#", ...) == 0 then
self:AddLine(label, self:ApplyValueColor(text))
else
self:AddLine(label, self:ApplyValueColor(text).." ("..self:_TextsToStr(...)..")")
end
end
function TooltipBuilder.AddValueLine(self, label, ...)
self:AddLine(label, self:_ValuesToStr(...))
end
function TooltipBuilder.AddItemValueLine(self, label, value)
if self._quantity > 1 then
label = label.." x"..self._quantity
value = value * self._quantity
end
self:AddLine(label, self:FormatMoney(value))
end
function TooltipBuilder.AddQuantityValueLine(self, label, quantity, ...)
self:AddLine(label, self:ApplyValueColor(quantity).." ("..self:_ValuesToStr(...)..")")
end
function TooltipBuilder.AddSubItemValueLine(self, itemString, value, multiplier, matRate, minAmount, maxAmount)
local name = ItemInfo.GetName(itemString)
local color = ItemInfo.GetQualityColor(itemString)
if not name or not color then
return
end
multiplier = Math.Round(multiplier * self._quantity, 0.001)
matRate = matRate and matRate * 100
matRate = matRate and matRate.."% " or ""
local range = (minAmount and maxAmount) and (minAmount ~= maxAmount and "|cffffff00 ["..minAmount.."-"..maxAmount.."]|r" or "|cffffff00 ["..minAmount.."]|r") or ""
value = value and (value * multiplier) or nil
self:AddLine(color..matRate..name.." x"..multiplier.."|r"..range, self:FormatMoney(value))
end
function TooltipBuilder.StartSection(self, headingTextLeft, headingTextRight)
if self._level == 0 then
headingTextLeft = Theme.GetColor("INDICATOR"):ColorText(headingTextLeft)
end
if headingTextRight then
self:AddLine(headingTextLeft, headingTextRight)
elseif headingTextLeft then
self:AddLine(headingTextLeft)
end
self._level = self._level + 1
self._levelHasHeading[self._level] = headingTextLeft and true or false
self._levelNumLines[self._level] = 0
end
function TooltipBuilder.EndSection(self)
if self._levelNumLines[self._level] == 0 and self._levelHasHeading[self._level] then
-- remove the previous heading line
local headingLine = tremove(self._lines)
assert(headingLine)
self._lineColors[headingLine] = nil
self._levelNumLines[self._level - 1] = self._levelNumLines[self._level - 1] - 1
end
self._level = self._level - 1
assert(self._level >= 0)
end
function TooltipBuilder.SetDisabled(self, disabled)
self._disabled = disabled
end
function TooltipBuilder.GetNumLines(self)
return #self._lines
end
function TooltipBuilder.GetLine(self, index)
local line = self._lines[index]
local left, right = strsplit(LINE_PART_SEP, line)
return left, right, self._lineColors[line]
end
-- ============================================================================
-- TooltipBuilder - Private Methods
-- ============================================================================
function TooltipBuilder._GetModifierHash(self)
return (IsShiftKeyDown() and 4 or 0) + (IsAltKeyDown() and 2 or 0) + (IsControlKeyDown() and 1 or 0)
end
function TooltipBuilder._IsCached(self, itemString, quantity)
if itemString == ItemString.GetPlaceholder() then
return false
end
if self:_GetModifierHash() ~= self._modifier then
return false
end
if self._itemString ~= itemString or self._quantity ~= quantity then
return false
end
if GetTime() - self._lastUpdate >= TOOLTIP_CACHE_TIME then
return false
end
if InCombatLockdown() ~= self._inCombat then
return false
end
return true
end
function TooltipBuilder._Prepare(self, itemString, quantity)
if self:_IsCached(itemString, quantity) then
-- have the lines cached already
return true
end
wipe(self._lines)
wipe(self._lineColors)
wipe(self._levelNumLines)
wipe(self._levelHasHeading)
self._level = 0
self._levelNumLines[self._level] = 0
self._lastUpdate = GetTime()
self._modifier = self:_GetModifierHash()
self._inCombat = InCombatLockdown()
self._itemString = itemString
self._quantity = quantity
self._disabled = false
if InCombatLockdown() and itemString ~= ItemString.GetPlaceholder() then
self:AddLine(L["Can't load TSM tooltip while in combat"])
return true
else
return false
end
end
function TooltipBuilder._IsEmpty(self)
assert(self._level == 0)
return #self._lines == 0
end
function TooltipBuilder._LineIterator(self)
return private.LineIteratorHelper, self, 0
end
function TooltipBuilder._TextsToStr(self, ...)
local valueParts = TempTable.Acquire(...)
for i = 1, #valueParts do
valueParts[i] = self:ApplyValueColor(valueParts[i])
end
local result = table.concat(valueParts, " / ")
TempTable.Release(valueParts)
return result
end
function TooltipBuilder._ValuesToStr(self, ...)
-- can't just build the table with the vararg as there may be nils
local valueParts = TempTable.Acquire()
for i = 1, select("#", ...) do
local value = select(i, ...)
valueParts[i] = self:FormatMoney(value)
end
local result = table.concat(valueParts, " / ")
TempTable.Release(valueParts)
return result
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.LineIteratorHelper(self, index)
index = index + 1
if index > #self._lines then
return
end
return index, self:GetLine(index)
end

View File

@@ -0,0 +1,114 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ExtraTip = TSM.Init("Service.ItemTooltipClasses.ExtraTip")
local private = {
numExtraTips = 0,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function ExtraTip.Create(tooltip)
return private.CreateExtraTip(tooltip)
end
-- ============================================================================
-- Extra Tip Methods
-- ============================================================================
local EXTRA_TIP_METHODS = {
Attach = function(self, tooltip)
self:SetOwner(tooltip, "ANCHOR_PRESERVE")
self.anchorFrame = nil
self:OnUpdate()
end,
Show = function(self)
self.anchorFrame = nil
GameTooltip.Show(self)
local bottom = self:GetBottom()
if bottom and self:GetParent():IsShown() then
self.isTop = bottom <= 0
end
self:OnUpdate()
local numLines = self:NumLines()
local changedLines = self.changedLines or 0
if changedLines >= numLines then return end
for i = changedLines + 1, numLines do
local left, right = self.Left[i], self.Right[i]
local font = i == 1 and GameTooltipHeader or GameFontNormal
local r, g, b, a = left:GetTextColor()
left:SetFontObject(font)
left:SetTextColor(r, g, b, a)
r, g, b, a = right:GetTextColor()
right:SetFontObject(font)
right:SetTextColor(r, g, b, a)
end
self.changedLines = numLines
GameTooltip.Show(self)
self:OnUpdate()
end,
OnUpdate = function(self)
local tooltip = self:GetParent()
local anchorFrame = private.GetExtraTipFrame(tooltip, tooltip:GetChildren()) or tooltip
if anchorFrame ~= self.anchorFrame then
self:ClearAllPoints()
if self.isTop then
self:SetPoint("BOTTOM", anchorFrame, "TOP")
else
self:SetPoint("TOP", anchorFrame, "BOTTOM")
end
self.anchorFrame = anchorFrame
end
end,
}
local LINE_METATABLE = {
__index = function(t, k)
local v = _G[t.name..k]
rawset(t, k, v)
return v
end,
}
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.CreateExtraTip(tooltip)
private.numExtraTips = private.numExtraTips + 1
local extraTip = CreateFrame("GameTooltip", "TSMExtraTip"..private.numExtraTips, tooltip, "GameTooltipTemplate")
extraTip:SetClampedToScreen(true)
for name, func in pairs(EXTRA_TIP_METHODS) do
extraTip[name] = func
end
extraTip.Left = setmetatable({name = extraTip:GetName().."TextLeft"}, LINE_METATABLE)
extraTip.Right = setmetatable({name = extraTip:GetName().."TextRight"}, LINE_METATABLE)
return extraTip
end
function private.GetExtraTipFrame(tooltip, ...)
for i = 1, select('#', ...) do
local frame = select(i, ...)
if frame.InitLines and frame:GetParent() == tooltip and frame:IsVisible() then
return frame
end
end
end

View File

@@ -0,0 +1,320 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Wrapper = TSM.Init("Service.ItemTooltipClasses.Wrapper")
local ExtraTip = TSM.Include("Service.ItemTooltipClasses.ExtraTip")
local Builder = TSM.Include("Service.ItemTooltipClasses.Builder")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Settings = TSM.Include("Service.Settings")
local ItemString = TSM.Include("Util.ItemString")
local private = {
builder = nil,
settings = nil,
tooltipRegistry = {},
hookedBattlepetGlobal = nil,
tooltipMethodPrehooks = nil,
tooltipMethodPosthooks = {},
lastMailTooltipUpdate = nil,
lastMailTooltipIndex = nil,
populateFunc = nil,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
Wrapper:OnSettingsLoad(function()
private.builder = Builder.Create()
private.settings = Settings.NewView()
:AddKey("global", "tooltipOptions", "embeddedTooltip")
:AddKey("global", "tooltipOptions", "enabled")
:AddKey("global", "tooltipOptions", "tooltipShowModifier")
private.RegisterTooltip(GameTooltip)
private.RegisterTooltip(ItemRefTooltip)
if not TSM.IsWowClassic() then
private.RegisterTooltip(BattlePetTooltip)
private.RegisterTooltip(FloatingBattlePetTooltip)
end
local orig = OpenMailAttachment_OnEnter
OpenMailAttachment_OnEnter = function(self, index)
private.lastMailTooltipUpdate = private.lastMailTooltipUpdate or 0
if private.lastMailTooltipIndex ~= index or private.lastMailTooltipUpdate + 0.1 < GetTime() then
private.lastMailTooltipUpdate = GetTime()
private.lastMailTooltipIndex = index
orig(self, index)
end
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Wrapper.SetPopulateFunction(func)
assert(type(func) == "function" and not private.populateFunc)
private.populateFunc = func
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.RegisterTooltip(tooltip)
local reg = {}
reg.extraTip = ExtraTip.Create(tooltip)
private.tooltipRegistry[tooltip] = reg
if private.IsBattlePetTooltip(tooltip) then
if not private.hookedBattlepetGlobal then
private.hookedBattlepetGlobal = true
hooksecurefunc("BattlePetTooltipTemplate_SetBattlePet", private.OnTooltipSetBattlePet)
hooksecurefunc("BattlePetToolTip_Show", private.OnBattlePetTooltipShow)
end
tooltip:HookScript("OnHide", private.OnTooltipCleared)
else
local scriptHooks = {
OnTooltipSetItem = private.OnTooltipSetItem,
OnTooltipCleared = private.OnTooltipCleared
}
for script, prehook in pairs(scriptHooks) do
tooltip:HookScript(script, prehook)
end
for method, prehook in pairs(private.tooltipMethodPrehooks) do
local posthook = private.tooltipMethodPosthooks[method]
local orig = tooltip[method]
tooltip[method] = function(...)
prehook(...)
local a, b, c, d, e, f, g, h, i, j, k = orig(...)
posthook(...)
return a, b, c, d, e, f, g, h, i, j, k
end
end
end
end
function private.IsBattlePetTooltip(tooltip)
if TSM.IsWowClassic() then
return false
end
return tooltip == BattlePetTooltip or tooltip == FloatingBattlePetTooltip
end
function private.OnTooltipSetItem(tooltip)
local reg = private.tooltipRegistry[tooltip]
if reg.hasItem then
return
end
tooltip:Show()
local testName, item = tooltip:GetItem()
if not item then
item = reg.item
elseif testName == "" then
-- this is likely a case where :GetItem() is broken for recipes - detect and try to fix it
if strmatch(item, "item:([0-9]*):") == "" then
item = reg.item
end
end
if not item then
return
end
private.SetTooltipItem(tooltip, item)
end
function private.OnTooltipSetBattlePet(tooltip, data)
local reg = private.tooltipRegistry[tooltip]
if reg.hasItem then
private.OnTooltipCleared(tooltip)
end
local link = reg.item
if not link then
-- extract values from data
local speciesID = data.speciesID
local level = data.level
local maxHealth = data.maxHealth
local power = data.power
local speed = data.speed
local battlePetID = data.battlePetID or "0x0000000000000000"
local name = data.name
local customName = data.customName
local breedQuality = data.breedQuality
local colorCode = breedQuality == -1 and NORMAL_FONT_COLOR_CODE or (ITEM_QUALITY_COLORS[breedQuality] or ITEM_QUALITY_COLORS[0]).hex
link = format("%s|Hbattlepet:%d:%d:%d:%d:%d:%d:%s|h[%s]|h|r", colorCode, speciesID, level, breedQuality, maxHealth, power, speed, battlePetID, customName or name)
end
private.SetTooltipItem(tooltip, link)
end
function private.OnTooltipCleared(tooltip)
local reg = private.tooltipRegistry[tooltip]
if reg.ignoreOnCleared then return end
reg.extraTipUsed = nil
reg.minWidth = 0
reg.quantity = nil
reg.hasItem = nil
reg.item = nil
reg.extraTip:Hide()
reg.extraTip.minWidth = 0
reg.extraTip.isTop = nil
end
function private.OnBattlePetTooltipShow()
local reg = private.tooltipRegistry[BattlePetTooltip]
reg.extraTip:Show()
end
function private.SetTooltipItem(tooltip, link)
local itemString = ItemString.Get(link)
if not private.IsEnabled() or not itemString then
return
end
local reg = private.tooltipRegistry[tooltip]
local quantity = max(IsShiftKeyDown() and reg.quantity or 1, 1)
local isCached = private.builder:_Prepare(itemString, quantity)
if not isCached then
-- populate all the lines
private.populateFunc(private.builder, itemString)
end
if private.builder:_IsEmpty() then
return
end
reg.hasItem = true
local useExtraTip = private.IsBattlePetTooltip(tooltip) or not private.settings.embeddedTooltip
-- setup the extra tip if necessary
if useExtraTip then
reg.extraTip:Attach(tooltip)
local r, g, b = GetItemQualityColor(ItemInfo.GetQuality(link) or 0)
reg.extraTip:AddLine(ItemInfo.GetName(link), r, g, b)
end
-- add all the lines
local targetTip = useExtraTip and reg.extraTip or tooltip
targetTip:AddLine(" ")
for _, left, right, lineColor in private.builder:_LineIterator() do
local r, g, b = lineColor:GetFractionalRGBA()
if right then
targetTip:AddDoubleLine(left, right, r, g, b, r, g, b)
else
targetTip:AddLine(left, r, g, b)
end
end
-- show the tooltip / extra tip as necessary
if not private.IsBattlePetTooltip(tooltip) then
tooltip:Show()
end
if useExtraTip then
reg.extraTip:Show()
end
end
function private.IsEnabled()
if not private.settings.enabled then
return false
elseif private.settings.tooltipShowModifier == "alt" and not IsAltKeyDown() then
return false
elseif private.settings.tooltipShowModifier == "ctrl" and not IsControlKeyDown() then
return false
end
return true
end
-- ============================================================================
-- Hook Setup Code
-- ============================================================================
do
local function PreHookHelper(self, quantityFunc, quantityOffset, ...)
private.OnTooltipCleared(self)
local reg = private.tooltipRegistry[self]
reg.ignoreOnCleared = true
if type(quantityFunc) == "number" then
reg.quantity = quantityFunc
else
reg.quantity = select(quantityOffset, quantityFunc(...))
end
return reg
end
private.tooltipMethodPrehooks = {
SetQuestItem = function(self, ...) PreHookHelper(self, GetQuestItemInfo, 3, ...) end,
SetQuestLogItem = function(self, type, ...)
local quantityFunc = type == "choice" and GetQuestLogChoiceInfo or GetQuestLogRewardInfo
PreHookHelper(self, quantityFunc, 3, ...)
end,
SetRecipeReagentItem = function(self, ...)
local reg = PreHookHelper(self, C_TradeSkillUI.GetRecipeReagentInfo, 3, ...)
reg.item = C_TradeSkillUI.GetRecipeReagentItemLink(...)
end,
SetRecipeResultItem = function(self, ...)
private.OnTooltipCleared(self)
local reg = private.tooltipRegistry[self]
reg.ignoreOnCleared = true
local lNum, hNum = C_TradeSkillUI.GetRecipeNumItemsProduced(...)
-- the quantity can be a range, so use a quantity of 1 if so
reg.quantity = lNum == hNum and lNum or 1
end,
SetBagItem = function(self, ...) PreHookHelper(self, GetContainerItemInfo, 2, ...) end,
SetGuildBankItem = function(self, ...)
local reg = PreHookHelper(self, GetGuildBankItemInfo, 2, ...)
reg.item = GetGuildBankItemLink(...)
end,
SetVoidItem = function(self, ...) PreHookHelper(self, 1) end,
SetVoidDepositItem = function(self, ...) PreHookHelper(self, 1) end,
SetVoidWithdrawalItem = function(self, ...) PreHookHelper(self, 1) end,
SetInventoryItem = function(self, ...) PreHookHelper(self, GetInventoryItemCount, 1, ...) end,
SetMerchantItem = function(self, ...)
local reg = PreHookHelper(self, GetMerchantItemInfo, 4, ...)
reg.item = GetMerchantItemLink(...)
end,
SetMerchantCostItem = function(self, ...) PreHookHelper(self, GetMerchantItemCostItem, 2, ...) end,
SetBuybackItem = function(self, ...) PreHookHelper(self, GetBuybackItemInfo, 4, ...) end,
SetAuctionItem = function(self, ...)
local reg = PreHookHelper(self, GetAuctionItemInfo, 3, ...)
reg.item = GetAuctionItemLink(...)
end,
SetAuctionSellItem = function(self, ...) PreHookHelper(self, GetAuctionSellItemInfo, 3, ...) end,
SetInboxItem = function(self, index) PreHookHelper(self, GetInboxItem, 4, index, 1) end,
SetSendMailItem = function(self, ...) PreHookHelper(self, GetSendMailItem, 4, ...) end,
SetLootItem = function(self, ...) PreHookHelper(self, GetLootSlotInfo, 3, ...) end,
SetLootRollItem = function(self, ...) PreHookHelper(self, GetLootRollItemInfo, 3, ...) end,
SetTradePlayerItem = function(self, ...) PreHookHelper(self, GetTradePlayerItemInfo, 3, ...) end,
SetTradeTargetItem = function(self, ...) PreHookHelper(self, GetTradeTargetItemInfo, 3, ...) end,
SetHyperlink = function(self, link)
local reg = private.tooltipRegistry[self]
private.OnTooltipCleared(self)
reg.ignoreOnCleared = true
reg.item = link
end,
}
-- populate all the posthooks
local function TooltipMethodPostHook(self)
private.tooltipRegistry[self].ignoreOnCleared = nil
end
for funcName in pairs(private.tooltipMethodPrehooks) do
private.tooltipMethodPosthooks[funcName] = TooltipMethodPostHook
end
-- SetHyperlink is special
private.tooltipMethodPosthooks.SetHyperlink = function(self)
local reg = private.tooltipRegistry[self]
reg.ignoreOnCleared = nil
end
end

View File

@@ -0,0 +1,398 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local MailTracking = TSM.Init("Service.MailTracking")
local Database = TSM.Include("Util.Database")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Settings = TSM.Include("Service.Settings")
local AuctionTracking = TSM.Include("Service.AuctionTracking")
local private = {
settings = nil,
mailDB = nil,
itemDB = nil,
quantityDB = nil,
isOpen = false,
tooltip = nil,
callbacks = {},
expiresCallbacks = {},
cancelAuctionQuery = nil,
}
local PLAYER_NAME = UnitName("player")
-- ============================================================================
-- Module Loading
-- ============================================================================
MailTracking:OnSettingsLoad(function()
private.settings = Settings.NewView()
:AddKey("factionrealm", "internalData", "pendingMail")
:AddKey("factionrealm", "internalData", "expiringMail")
:AddKey("sync", "internalData", "mailQuantity")
-- update the structure of TSM.db.factionrealm.internalData.pendingMail
local toUpdate = TempTable.Acquire()
for character, pendingMailData in pairs(private.settings.pendingMail) do
if pendingMailData.items then
Log.Info("Converting pending mail data for %s", character)
toUpdate[character] = pendingMailData.items
end
end
for character, items in pairs(toUpdate) do
private.settings.pendingMail[character] = items
end
TempTable.Release(toUpdate)
-- remove data for characters we don't own
local toRemove = TempTable.Acquire()
for character in pairs(private.settings.pendingMail) do
if Settings.GetCharacterSyncAccountKey(character) ~= Settings.GetCurrentSyncAccountKey() then
Log.Info("Removed pending mail data for %s", character)
tinsert(toRemove, character)
end
end
for _, character in ipairs(toRemove) do
private.settings.pendingMail[character] = nil
end
TempTable.Release(toRemove)
private.mailDB = Database.NewSchema("MAIL_TRACKING_INBOX_INFO")
:AddUniqueNumberField("index")
:AddStringField("icon")
:AddStringField("sender")
:AddStringField("subject")
:AddStringField("itemString")
:AddNumberField("itemCount")
:AddNumberField("money")
:AddNumberField("cod")
:AddNumberField("expires")
:AddIndex("index")
:Commit()
private.itemDB = Database.NewSchema("MAIL_TRACKING_INBOX_ITEMS")
:AddNumberField("index")
:AddNumberField("itemIndex")
:AddStringField("itemLink")
:AddNumberField("quantity")
:Commit()
private.quantityDB = Database.NewSchema("MAIL_TRACKING_QUANTITY")
:AddUniqueStringField("itemString")
:AddNumberField("quantity")
:Commit()
private.settings.pendingMail[PLAYER_NAME] = private.settings.pendingMail[PLAYER_NAME] or {}
Event.Register("MAIL_SHOW", private.MailShowHandler)
Event.Register("MAIL_CLOSED", private.MailClosedHandler)
Event.Register("MAIL_INBOX_UPDATE", private.MailInboxUpdateHandler)
if TSM.IsWowClassic() then
-- handle auction buying
hooksecurefunc("PlaceAuctionBid", function(listType, index, bidPlaced)
local itemString = ItemString.GetBase(GetAuctionItemLink(listType, index))
local _, _, stackSize, _, _, _, _, _, _, buyout = GetAuctionItemInfo(listType, index)
if not itemString or bidPlaced ~= buyout then
return
end
private.ChangePendingMailQuantity(itemString, stackSize)
end)
-- handle auction canceling
hooksecurefunc("CancelAuction", function(index)
local itemString = ItemString.GetBase(GetAuctionItemLink("owner", index))
local _, _, stackSize = GetAuctionItemInfo("owner", index)
-- for some reason, these APIs don't always work properly, so check the return values
if not itemString or not stackSize or stackSize == 0 then
return
end
private.ChangePendingMailQuantity(itemString, stackSize)
end)
else
private.cancelAuctionQuery = AuctionTracking.CreateQuery()
:Equal("auctionId", Database.BoundQueryParam())
:Select("itemString", "stackSize")
-- handle auction canceling
hooksecurefunc(C_AuctionHouse, "CancelAuction", function(auctionId)
private.cancelAuctionQuery:BindParams(auctionId)
for _, itemString, stackSize in private.cancelAuctionQuery:Iterator() do
private.ChangePendingMailQuantity(itemString, stackSize)
end
end)
end
-- handle sending mail to alts
hooksecurefunc("SendMail", function(target)
local character = private.ValidateCharacter(target)
if not character then
return
end
private.settings.pendingMail[character] = private.settings.pendingMail[character] or {}
local altPendingMail = private.settings.pendingMail[character]
for i = 1, ATTACHMENTS_MAX_SEND do
local itemString = ItemString.GetBase(GetSendMailItemLink(i))
local _, _, _, quantity = GetSendMailItem(i)
if itemString and quantity then
altPendingMail[itemString] = (altPendingMail[itemString] or 0) + quantity
end
end
end)
-- handle returning mail to alts
hooksecurefunc("ReturnInboxItem", function(index)
local character = private.ValidateCharacter(select(3, GetInboxHeaderInfo(index)))
if not character then
return
end
private.settings.pendingMail[character] = private.settings.pendingMail[character] or {}
local altPendingMail = private.settings.pendingMail[character]
for i = 1, ATTACHMENTS_MAX_SEND do
local _, _, _, quantity = GetInboxItem(index, i)
local itemLink = quantity and quantity > 0 and private.GetInboxItemLink(index, i) or nil
local itemString = itemLink and ItemString.GetBase(itemLink) or nil
if itemString then
altPendingMail[itemString] = (altPendingMail[itemString] or 0) + quantity
end
end
end)
private.quantityDB:BulkInsertStart()
for itemString, quantity in pairs(private.settings.pendingMail[PLAYER_NAME]) do
if quantity > 0 then
private.quantityDB:BulkInsertNewRow(itemString, quantity)
else
private.settings.pendingMail[PLAYER_NAME][itemString] = nil
end
end
private.quantityDB:BulkInsertEnd()
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function MailTracking.RegisterCallback(callback)
tinsert(private.callbacks, callback)
end
function MailTracking.RegisterExpiresCallback(callback)
tinsert(private.expiresCallbacks, callback)
end
function MailTracking.BaseItemIterator()
return private.quantityDB:NewQuery()
:Select("itemString")
:IteratorAndRelease()
end
function MailTracking.CreateMailInboxQuery()
return private.mailDB:NewQuery()
end
function MailTracking.CreateMailItemQuery()
return private.itemDB:NewQuery()
end
function MailTracking.GetInboxItemLink(index)
return private.GetInboxItemLink(index, 1)
end
function MailTracking.GetMailType(index)
return private.GetMailType(index)
end
function MailTracking.GetQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "quantity") or 0
end
function MailTracking.RecordAuctionBuyout(baseItemString, stackSize)
if TSM.IsWowClassic() then
-- on classic, we'll handle auction buys via a direct hook
return
end
private.ChangePendingMailQuantity(baseItemString, stackSize)
end
-- ============================================================================
-- Event Handlers
-- ============================================================================
function private.MailShowHandler()
private.isOpen = true
end
function private.MailClosedHandler()
private.isOpen = false
end
function private.MailInboxUpdateHandler()
if not private.isOpen then
return
end
Delay.AfterFrame("mailInboxScan", 1, private.MailInboxUpdateDelayed)
end
function private.MailInboxUpdateDelayed()
if not private.isOpen then
return
end
wipe(private.settings.mailQuantity)
wipe(private.settings.pendingMail[PLAYER_NAME])
private.mailDB:TruncateAndBulkInsertStart()
private.itemDB:TruncateAndBulkInsertStart()
local expiration = math.huge
for i = 1, GetInboxNumItems() do
local _, _, sender, subject, money, cod, daysLeft, itemCount = GetInboxHeaderInfo(i)
if itemCount and itemCount > 0 and money and money > 0 then
expiration = min(expiration, time() + (daysLeft * 24 * 60 * 60))
end
if money and money > 0 then
private.itemDB:BulkInsertNewRow(i, 0, tostring(money), 0)
end
local firstItemString = nil
for j = 1, ATTACHMENTS_MAX do
local _, _, _, quantity = GetInboxItem(i, j)
local itemLink = quantity and quantity > 0 and private.GetInboxItemLink(i, j) or nil
local itemString = itemLink and ItemString.Get(itemLink) or nil
if itemString then
firstItemString = firstItemString or itemString
local baseItemString = ItemString.GetBaseFast(itemString)
private.settings.mailQuantity[baseItemString] = (private.settings.mailQuantity[baseItemString] or 0) + quantity
private.itemDB:BulkInsertNewRow(i, j, itemLink, quantity)
end
end
local mailType = private.GetMailType(i, firstItemString) or ""
if mailType == "BUY" then
local _, _, _, bid = GetInboxInvoiceInfo(i)
cod = bid
end
private.mailDB:BulkInsertNewRow(i, mailType, sender or UNKNOWN, subject or "--", firstItemString or "", itemCount or 0, money or 0, cod or 0, daysLeft)
end
private.quantityDB:TruncateAndBulkInsertStart()
for itemString, quantity in pairs(private.settings.mailQuantity) do
private.quantityDB:BulkInsertNewRow(itemString, quantity)
end
private.quantityDB:BulkInsertEnd()
private.itemDB:BulkInsertEnd()
private.mailDB:BulkInsertEnd()
private.settings.expiringMail[PLAYER_NAME] = expiration ~= math.huge and expiration or nil
for _, callback in ipairs(private.expiresCallbacks) do
callback()
end
for _, callback in ipairs(private.callbacks) do
callback()
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ChangePendingMailQuantity(itemString, quantity)
assert(quantity ~= 0)
private.settings.pendingMail[PLAYER_NAME][itemString] = (private.settings.pendingMail[PLAYER_NAME][itemString] or 0) + quantity
if not private.quantityDB:HasUniqueRow("itemString", itemString) then
-- create a new row
private.quantityDB:NewRow()
:SetField("itemString", itemString)
:SetField("quantity", quantity)
:Create()
else
local row = private.quantityDB:GetUniqueRow("itemString", itemString)
local newValue = row:GetField("quantity") + quantity
assert(newValue >= 0)
if newValue == 0 then
-- remove this row
private.quantityDB:DeleteRow(row)
else
-- update this row
row:SetField("quantity", newValue)
:Update()
end
row:Release()
end
for _, callback in ipairs(private.callbacks) do
callback()
end
end
function private.ValidateCharacter(character)
if not character then
return
end
local characterName, realm = strsplit("-", strlower(character))
-- we only care to track mails with characters on this realm
if realm and realm ~= strlower(GetRealmName()) then
return
end
-- we only care to track mails with characters on this account
local result = nil
for _, name in Settings.CharacterByAccountFactionrealmIterator() do
if strlower(name) == characterName then
result = name
end
end
return result
end
function private.GetInboxItemLink(index, num)
local link = GetInboxItemLink(index, num)
if ItemString.GetBase(link) ~= ItemString.GetPetCage() then
return link
end
-- need to do tooltip scanning to get battlepet links
private.tooltip = private.tooltip or CreateFrame("GameTooltip", "TSM4MailingInboxTooltip", UIParent, "GameTooltipTemplate")
private.tooltip:SetOwner(UIParent, "ANCHOR_NONE")
private.tooltip:ClearLines()
local _, speciesId, level, breedQuality = private.tooltip:SetInboxItem(index, num)
assert(speciesId and speciesId > 0)
private.tooltip:Hide()
return ItemInfo.GetLink(strjoin(":", "p", speciesId, level, breedQuality))
end
function private.GetMailType(index, firstItemString)
local _, _, _, subject, money, cod, _, numItems, _, _, _, _, isGM = GetInboxHeaderInfo(index)
if isGM or (cod and cod > 0) or (money == 0 and (not numItems or numItems == 0)) then
return nil
end
local info = GetInboxInvoiceInfo(index)
if money and money > 0 and info == "seller" then
return "SALE"
elseif numItems and numItems > 0 and info == "buyer" then
return "BUY"
elseif not info then
if strfind(subject, string.gsub("^"..AUCTION_REMOVED_MAIL_SUBJECT, "%%s", "")) then
return "CANCEL"
end
if strfind(subject, string.gsub("^"..AUCTION_EXPIRED_MAIL_SUBJECT, "%%s", "")) then
return "EXPIRE"
end
end
return "OTHER"
end

View File

@@ -0,0 +1,157 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- PlayerInfo Functions
-- @module PlayerInfo
local _, TSM = ...
local PlayerInfo = TSM.Init("Service.PlayerInfo")
local String = TSM.Include("Util.String")
local Settings = TSM.Include("Service.Settings")
local private = {
connectedAlts = {},
settings = nil,
isPlayerCache = {},
}
local PLAYER_NAME = UnitName("player")
local PLAYER_LOWER = strlower(PLAYER_NAME)
local FACTION_LOWER = strlower(UnitFactionGroup("player"))
local REALM_LOWER = strlower(GetRealmName())
local PLAYER_REALM_LOWER = PLAYER_LOWER.." - "..REALM_LOWER
-- ============================================================================
-- Module Loading
-- ============================================================================
PlayerInfo:OnSettingsLoad(function()
private.settings = Settings.NewView()
:AddKey("factionrealm", "internalData", "guildVaults")
:AddKey("factionrealm", "coreOptions", "ignoreGuilds")
:AddKey("factionrealm", "internalData", "characterGuilds")
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Return all connected realm alternative characters.
-- @return table The populated alternative characters.
function PlayerInfo.GetConnectedAlts()
wipe(private.connectedAlts)
for factionrealm in TSM.db:GetConnectedRealmIterator("factionrealm") do
for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do
local realm = strmatch(factionrealm, ".+ %- (.+)")
character = Ambiguate(gsub(strmatch(character, "(.*) ?"..String.Escape("-").."?").."-"..gsub(realm, String.Escape("-"), ""), " ", ""), "none")
if character ~= UnitName("player") then
tinsert(private.connectedAlts, character)
end
end
end
sort(private.connectedAlts)
return private.connectedAlts
end
--- Iterate over all characters on this factionrealm.
-- @tparam[opt=false] boolean currentAccountOnly If true, will only include the current account
-- @return An iterator with the following fields: `index, name`
function PlayerInfo.CharacterIterator(currentAccountOnly)
if currentAccountOnly then
return Settings.CharacterByAccountFactionrealmIterator()
else
return Settings.FactionrealmCharacterIterator()
end
end
--- Iterate over all guilds on this factionrealm.
-- @tparam[opt=false] boolean includeIgnored If true, will include guilds which have been set to be ignored
-- @return An iterator with the following fields: `index, guildName`
function PlayerInfo.GuildIterator(includeIgnored)
if includeIgnored then
return private.GuildIteratorIgnoreIncluded, private.settings.guildVaults
else
return private.GuildIterator, private.settings.guildVaults
end
end
--- Get the player's guild.
-- @tparam string player The name of the player
-- @treturn ?string The name of the player's guilde or nil if it's not in one
function PlayerInfo.GetPlayerGuild(player)
return player and private.settings.characterGuilds[player] or nil
end
--- Check whether or not a player belongs to the user.
-- @tparam string target The name of the player
-- @tparam boolean includeAlts Whether or not to include alts
-- @tparam boolean includeOtherFaction Whether or not to include players on the other faction
-- @tparam boolean includeOtherAccounts Whether or not to include connected accounts
-- @treturn boolean Whether or not the player belongs to the user
function PlayerInfo.IsPlayer(target, includeAlts, includeOtherFaction, includeOtherAccounts)
local cacheKey = strjoin("%", target, includeAlts and "1" or "0", includeOtherFaction and "1" or "0", includeOtherAccounts and "1" or "0")
if private.isPlayerCache.lastUpdate ~= GetTime() then
wipe(private.isPlayerCache)
private.isPlayerCache.lastUpdate = GetTime()
end
if private.isPlayerCache[cacheKey] == nil then
private.isPlayerCache[cacheKey] = private.IsPlayerHelper(target, includeAlts, includeOtherFaction, includeOtherAccounts)
end
return private.isPlayerCache[cacheKey]
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.IsPlayerHelper(target, includeAlts, includeOtherFaction, includeOtherAccounts)
target = strlower(target)
if not strfind(target, " %- ") then
target = gsub(target, "%-", " - ", 1)
end
if target == PLAYER_LOWER then
return true
elseif strfind(target, " %- ") and target == PLAYER_REALM_LOWER then
return true
end
if not strfind(target, " %- ") then
target = target.." - "..REALM_LOWER
end
if includeAlts then
local result = false
for _, factionrealm, character in Settings.ConnectedFactionrealmAltCharacterIterator() do
local factionKey, realm = strmatch(factionrealm, "(.+) %- (.+)")
factionKey = strlower(factionKey)
if not result and target == strlower(character).." - "..strlower(realm) and (includeOtherFaction or factionKey == FACTION_LOWER) and (includeOtherAccounts or Settings.IsCurrentAccountOwner(character)) then
result = true
end
end
return result
end
return false
end
function private.GuildIterator(tbl, prevName)
while true do
local name = next(tbl, prevName)
if not name then
return nil
end
if not private.settings.ignoreGuilds[name] then
return name
end
prevName = name
end
end
function private.GuildIteratorIgnoreIncluded(tbl, prevName)
local name = next(tbl, prevName)
return name
end

1630
LibTSM/Service/Settings.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local SlashCommands = TSM.Init("Service.SlashCommands")
local Log = TSM.Include("Util.Log")
local L = TSM.Include("Locale").GetTable()
local private = {
commandInfo = {},
commandOrder = {},
}
local CALLBACK_TIME_WARNING_MS = 20
-- ============================================================================
-- Module Loading
-- ============================================================================
SlashCommands:OnModuleLoad(function()
-- register the TSM slash commands
SlashCmdList["TSM"] = private.OnChatCommand
SlashCmdList["TRADESKILLMASTER"] = private.OnChatCommand
_G["SLASH_TSM1"] = "/tsm"
_G["SLASH_TRADESKILLMASTER1"] = "/tradeskillmaster"
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function SlashCommands.Register(key, callback, label)
assert(key and callback)
local keyLower = strlower(key)
private.commandInfo[keyLower] = {
key = key,
label = label,
callback = callback,
}
tinsert(private.commandOrder, keyLower)
end
function SlashCommands.PrintHelp()
Log.PrintUser(L["Slash Commands:"])
for _, key in ipairs(private.commandOrder) do
local info = private.commandInfo[key]
if info.label then
if info.key == "" then
Log.PrintfUserRaw("|cffffaa00/tsm|r - %s", info.label)
else
Log.PrintfUserRaw("|cffffaa00/tsm %s|r - %s", info.key, info.label)
end
end
end
end
-- ============================================================================
-- Helper Functions
-- ============================================================================
function private.OnChatCommand(input)
input = strtrim(input)
local cmd, args = strmatch(input, "^([^ ]*) ?(.*)$")
cmd = strlower(cmd)
if private.commandInfo[cmd] then
local startTime = debugprofilestop()
private.commandInfo[cmd].callback(args)
local timeTaken = debugprofilestop() - startTime
if timeTaken > CALLBACK_TIME_WARNING_MS then
Log.Warn("Handler for slash command (/tsm%s) took %0.2fms", input ~= "" and " "..input or input, timeTaken)
end
else
-- We weren't able to handle this command so print out the help
SlashCommands.PrintHelp()
end
end

57
LibTSM/Service/Sync.lua Normal file
View File

@@ -0,0 +1,57 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Sync = TSM.Init("Service.Sync")
local Connection = TSM.Include("Service.SyncClasses.Connection")
local RPC = TSM.Include("Service.SyncClasses.RPC")
local Mirror = TSM.Include("Service.SyncClasses.Mirror")
-- ============================================================================
-- Module Functions
-- ============================================================================
function Sync.RegisterConnectionChangedCallback(callback)
Connection.RegisterConnectionChangedCallback(callback)
end
function Sync.RegisterRPC(name, func)
RPC.Register(name, func)
end
function Sync.CallRPC(name, targetPlayer, handler, ...)
return RPC.Call(name, targetPlayer, handler, ...)
end
function Sync.GetConnectionStatus(account)
return Connection.GetStatus(account)
end
function Sync.GetConnectedCharacterByAccount(account)
return Connection.GetConnectedCharacterByAccount(account)
end
function Sync.GetMirrorStatus(account)
return Mirror.GetStatus(account)
end
function Sync.RegisterMirrorCallback(callback)
Mirror.RegisterCallback(callback)
end
function Sync.EstablishConnection(character)
return Connection.Establish(character)
end
function Sync.GetNewAccountStatus()
return Connection.GetNewAccountStatus()
end
function Sync.RemoveAccount(account)
Connection.Remove(account)
end

View File

@@ -0,0 +1,134 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Comm = TSM.Init("Service.SyncClasses.Comm")
local Delay = TSM.Include("Util.Delay")
local Table = TSM.Include("Util.Table")
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local Settings = TSM.Include("Service.Settings")
local Constants = TSM.Include("Service.SyncClasses.Constants")
local private = {
handler = {},
queuedPacket = {},
queuedSourceCharacter = {},
}
-- load libraries
LibStub("AceComm-3.0"):Embed(Comm)
local LibSerialize = LibStub("LibSerialize")
local LibDeflate = LibStub("LibDeflate")
-- ============================================================================
-- Module Loading
-- ============================================================================
Comm:OnModuleLoad(function()
Comm:RegisterComm("TSMSyncData", private.OnCommReceived)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Comm.RegisterHandler(dataType, handler)
assert(Table.KeyByValue(Constants.DATA_TYPES, dataType) ~= nil)
assert(not private.handler[dataType])
private.handler[dataType] = handler
end
function Comm.SendData(dataType, targetCharacter, data)
assert(type(dataType) == "string" and #dataType == 1)
local packet = TempTable.Acquire()
packet.dt = dataType
packet.sa = Settings.GetCurrentSyncAccountKey()
packet.v = Constants.VERSION
packet.d = data
local serialized = LibSerialize:Serialize(packet)
TempTable.Release(packet)
local compressed = LibDeflate:EncodeForWoWAddonChannel(LibDeflate:CompressDeflate(serialized))
assert(LibDeflate:DecompressDeflate(LibDeflate:DecodeForWoWAddonChannel(compressed)) == serialized)
-- give heartbeats and rpc preambles a higher priority
local priority = (dataType == Constants.DATA_TYPES.HEARTBEAT or dataType == Constants.DATA_TYPES.RPC_PREAMBLE) and "ALERT" or nil
-- send the message
Comm:SendCommMessage("TSMSyncData", compressed, "WHISPER", targetCharacter, priority)
return #compressed
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.OnCommReceived(_, packet, _, sourceCharacter)
-- delay the processing to make sure it happens within a debuggable context (this function is called via pcall)
tinsert(private.queuedPacket, packet)
tinsert(private.queuedSourceCharacter, sourceCharacter)
Delay.AfterFrame("commReceiveQueue", 0, private.ProcessReceiveQueue)
end
function private.ProcessReceiveQueue()
assert(#private.queuedPacket == #private.queuedSourceCharacter)
while #private.queuedPacket > 0 do
local packet = tremove(private.queuedPacket, 1)
local sourceCharacter = tremove(private.queuedSourceCharacter, 1)
private.ProcessReceivedPacket(packet, sourceCharacter)
end
end
function private.ProcessReceivedPacket(msg, sourceCharacter)
-- remove realm name from source player
sourceCharacter = strsplit("-", sourceCharacter)
sourceCharacter = strtrim(sourceCharacter)
local sourceCharacterAccountKey = Settings.GetCharacterSyncAccountKey(sourceCharacter)
if sourceCharacterAccountKey and sourceCharacterAccountKey == Settings.GetCurrentSyncAccountKey() then
Log.Err("We own the source character")
Settings.ShowSyncSVCopyError()
return
end
-- decode and decompress
msg = LibDeflate:DecompressDeflate(LibDeflate:DecodeForWoWAddonChannel(msg))
if not msg then
Log.Err("Invalid packet")
return
end
local success, packet = LibSerialize:Deserialize(msg)
if not success then
Log.Err("Invalid packet")
return
end
-- validate the packet
local dataType = packet.dt
local sourceAccount = packet.sa
local version = packet.v
local data = packet.d
if type(dataType) ~= "string" or #dataType > 1 or not sourceAccount or version ~= Constants.VERSION then
Log.Info("Invalid message received")
return
elseif sourceAccount == Settings.GetCurrentSyncAccountKey() then
Log.Err("We are the source account (SV copy)")
Settings.ShowSyncSVCopyError()
return
elseif sourceCharacterAccountKey and sourceCharacterAccountKey ~= sourceAccount then
-- the source player now belongs to a different account than what we expect
Log.Err("Unexpected source account")
Settings.ShowSyncSVCopyError()
return
end
if private.handler[dataType] then
private.handler[dataType](dataType, sourceAccount, sourceCharacter, data)
else
Log.Info("Received unhandled message of type: "..strbyte(dataType))
end
end

View File

@@ -0,0 +1,443 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Connection = TSM.Init("Service.SyncClasses.Connection")
local L = TSM.Include("Locale").GetTable()
local Delay = TSM.Include("Util.Delay")
local Log = TSM.Include("Util.Log")
local TempTable = TSM.Include("Util.TempTable")
local Event = TSM.Include("Util.Event")
local Settings = TSM.Include("Service.Settings")
local Threading = TSM.Include("Service.Threading")
local Constants = TSM.Include("Service.SyncClasses.Constants")
local Comm = TSM.Include("Service.SyncClasses.Comm")
local private = {
isActive = false,
hasFriendsInfo = false,
newCharacter = nil,
newAccount = nil,
newSyncAcked = nil,
connectionChangedCallbacks = {},
threadId = {},
threadRunning = {},
connectedCharacter = {},
lastHeartbeat = {},
suppressThreadTime = {},
connectionRequestReceived = {},
addedFriends = {},
invalidCharacters = {},
}
local RECEIVE_TIMEOUT = 5
local HEARTBEAT_TIMEOUT = 10
-- ============================================================================
-- Module Loading
-- ============================================================================
Connection:OnSettingsLoad(function()
Event.Register("CHAT_MSG_SYSTEM", private.ChatMsgSystemEventHandler)
Event.Register("FRIENDLIST_UPDATE", private.PrepareFriendsInfo)
for _ in Settings.SyncAccountIterator() do
private.isActive = true
end
Comm.RegisterHandler(Constants.DATA_TYPES.WHOAMI_ACCOUNT, private.WhoAmIAccountHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.WHOAMI_ACK, private.WhoAmIAckHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CONNECTION_REQUEST, private.ConnectionHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CONNECTION_REQUEST_ACK, private.ConnectionHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.DISCONNECT, private.DisconnectHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.HEARTBEAT, private.HeartbeatHandler)
private.PrepareFriendsInfo()
end)
Connection:OnModuleUnload(function()
for _, player in pairs(private.connectedCharacter) do
Comm.SendData(Constants.DATA_TYPES.DISCONNECT, player)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Connection.RegisterConnectionChangedCallback(handler)
tinsert(private.connectionChangedCallbacks, handler)
end
function Connection.IsCharacterConnected(targetCharacter)
for _, player in pairs(private.connectedCharacter) do
if player == targetCharacter then
return true
end
end
return false
end
function Connection.ConnectedAccountIterator()
return pairs(private.connectedCharacter)
end
function Connection.Establish(targetCharacter)
if not private.hasFriendsInfo then
Log.PrintUser(L["TSM is not yet ready to establish a new sync connection. Please try again later."])
return false
end
local wasFriend = C_FriendList.GetFriendInfo(targetCharacter) and true or false
if strlower(targetCharacter) == strlower(UnitName("player")) then
Log.PrintUser(L["Sync Setup Error: You entered the name of the current character and not the character on the other account."])
return false
elseif not private.IsOnline(targetCharacter) and wasFriend then
Log.PrintUser(L["Sync Setup Error: The specified player on the other account is not currently online."])
return false
end
local invalidCharacter = false
for _, player in Settings.CharacterByFactionrealmIterator() do
if strlower(player) == strlower(targetCharacter) then
invalidCharacter = true
end
end
if invalidCharacter then
Log.PrintUser(L["Sync Setup Error: This character is already part of a known account."])
return false
end
if not private.isActive then
private.isActive = true
Delay.AfterTime("SYNC_CONNECTION_MANAGEMENT", 1, private.ManagementLoop, 1)
end
private.newCharacter = targetCharacter
private.newAccount = nil
private.newSyncAcked = nil
Delay.Cancel("syncNewAccount")
Delay.AfterTime("syncNewAccount", 0, private.SendNewAccountWhoAmI, 1)
return true
end
function Connection.GetNewAccountStatus()
if not private.newCharacter then
return nil
end
return format(L["Connecting to %s"], private.newCharacter)
end
function Connection.GetStatus(account)
if private.connectedCharacter[account] then
return true, private.connectedCharacter[account]
else
return false
end
end
function Connection.Remove(account)
if private.threadRunning[account] then
Threading.Kill(private.threadId[account])
private.ConnectionThreadDone(account)
end
Settings.RemoveSyncAccount(account)
end
function Connection.GetConnectedCharacterByAccount(account)
return private.connectedCharacter[account]
end
-- ============================================================================
-- Message Handlers
-- ============================================================================
function private.WhoAmIAckHandler(dataType, sourceAccount, sourceCharacter, data)
assert(dataType == Constants.DATA_TYPES.WHOAMI_ACK)
if not private.newCharacter or strlower(private.newCharacter) ~= strlower(sourceCharacter) then
-- we aren't trying to connect with a new account
return
end
Log.Info("WHOAMI_ACK '%s'", tostring(private.newCharacter))
private.newSyncAcked = true
private.CheckNewAccountStatus()
end
function private.WhoAmIAccountHandler(dataType, sourceAccount, sourceCharacter, data)
assert(dataType == Constants.DATA_TYPES.WHOAMI_ACCOUNT)
if not private.newCharacter then
-- we aren't trying to connect with a new account
return
elseif strlower(private.newCharacter) ~= strlower(sourceCharacter) then
Log.Info("WHOAMI_ACCOUNT from unknown player \"%s\", expected \"%s\"", private.newCharacter, sourceCharacter)
return
end
private.newCharacter = sourceCharacter -- get correct capatilization
private.newAccount = sourceAccount
Log.Info("WHOAMI_ACCOUNT '%s' '%s'", private.newCharacter, private.newAccount)
Comm.SendData(Constants.DATA_TYPES.WHOAMI_ACK, private.newCharacter)
private.CheckNewAccountStatus()
end
function private.ConnectionHandler(dataType, sourceAccount, sourceCharacter, data)
if not private.threadRunning[sourceAccount] then
return
end
private.connectionRequestReceived[sourceAccount] = true
end
function private.DisconnectHandler(dataType, sourceAccount, sourceCharacter, data)
assert(dataType == Constants.DATA_TYPES.DISCONNECT)
if not private.threadRunning[sourceAccount] then
return
end
-- kill the thread and prevent it from running again for 2 seconds
Threading.Kill(private.threadId[sourceAccount])
private.ConnectionThreadDone(sourceAccount)
private.suppressThreadTime[sourceAccount] = time() + 2
end
function private.HeartbeatHandler(dataType, sourceAccount, sourceCharacter)
assert(dataType == Constants.DATA_TYPES.HEARTBEAT)
if not Connection.IsCharacterConnected(sourceCharacter) then
-- we're not connected to this player
return
end
private.lastHeartbeat[sourceAccount] = time()
end
-- ============================================================================
-- Management Loop / Sync Thread
-- ============================================================================
function private.RequestFriendsInfo()
C_FriendList.ShowFriends()
end
function private.PrepareFriendsInfo()
-- wait for friend info to populate
local isValid
local num = C_FriendList.GetNumFriends()
if not num then
isValid = false
else
isValid = true
end
for i = 1, num or 0 do
if not C_FriendList.GetFriendInfoByIndex(i) then
isValid = false
break
end
end
if isValid then
if not private.hasFriendsInfo and private.isActive then
-- start the management loop
Delay.AfterTime("SYNC_CONNECTION_MANAGEMENT", 1, private.ManagementLoop, 1)
end
private.hasFriendsInfo = true
else
-- try again
Log.Err("Missing friends info - will try again")
Delay.AfterTime("SYNC_PREPARE_FRIENDS_INFO", 0.5, private.RequestFriendsInfo)
end
end
function private.ManagementLoop()
-- continuously spawn connection threads with online players as necessary
private.RequestFriendsInfo()
local hasAccount = false
for _, account in Settings.SyncAccountIterator() do
hasAccount = true
local targetCharacter = private.GetTargetCharacter(account)
if targetCharacter then
if not private.threadId[account] then
private.threadId[account] = Threading.New("SYNC_"..strmatch(account, "(%d+)$"), private.ConnectionThread)
end
if not private.threadRunning[account] and (private.suppressThreadTime[account] or 0) < time() then
private.threadRunning[account] = true
Threading.Start(private.threadId[account], account, targetCharacter)
end
end
end
if not hasAccount then
Log.Info("No more sync accounts.")
private.isActive = false
if not private.newCharacter then
Delay.Cancel("SYNC_CONNECTION_MANAGEMENT")
end
end
end
function private.ConnectionThreadInner(account, targetCharacter)
-- for the initial handshake, the lower account key is the server, other is the client - after this it doesn't matter
-- add some randomness to the timeout so we don't get stuck in a race condition
local timeout = GetTime() + RECEIVE_TIMEOUT + random(0, 1000) / 1000
if account < Settings.GetCurrentSyncAccountKey() then
-- wait for the connection request from the client
while not private.connectionRequestReceived[account] do
if GetTime() > timeout then
-- timed out on the connection - don't try again for a bit
Log.Warn("Timed out")
return
end
Threading.Yield(true)
end
-- send an connection request ACK back to the client
Comm.SendData(Constants.DATA_TYPES.CONNECTION_REQUEST_ACK, targetCharacter)
else
-- send a connection request to the server
Comm.SendData(Constants.DATA_TYPES.CONNECTION_REQUEST, targetCharacter)
-- wait for the connection request ACK
while not private.connectionRequestReceived[account] do
if GetTime() > timeout then
-- timed out on the connection - don't try again for a bit
Log.Warn("Timed out")
private.suppressThreadTime[account] = time() + RECEIVE_TIMEOUT
return
end
Threading.Yield(true)
end
end
-- we are now connected
Log.Info("Connected to: %s %s", account, targetCharacter)
private.connectedCharacter[account] = targetCharacter
private.lastHeartbeat[account] = time()
for _, callback in ipairs(private.connectionChangedCallbacks) do
callback(account, targetCharacter, true)
end
-- now that we are connected, data can flow in both directions freely
local lastHeartbeatSend = time()
while true do
-- check if they either logged off or the heartbeats have timed-out
if not private.IsOnline(targetCharacter, true) or time() - private.lastHeartbeat[account] > HEARTBEAT_TIMEOUT then
return
end
-- check if we should send a heartbeat
if time() - lastHeartbeatSend > floor(HEARTBEAT_TIMEOUT / 2) then
Comm.SendData(Constants.DATA_TYPES.HEARTBEAT, targetCharacter)
lastHeartbeatSend = time()
end
Threading.Yield(true)
end
end
function private.ConnectionThread(account, targetCharacter)
private.ConnectionThreadInner(account, targetCharacter)
private.ConnectionThreadDone(account)
end
function private.ConnectionThreadDone(account)
Log.Info("Connection ended to %s", account)
local player = private.connectedCharacter[account]
private.connectedCharacter[account] = nil
if player then
for _, callback in ipairs(private.connectionChangedCallbacks) do
callback(account, player, false)
end
end
private.threadRunning[account] = nil
private.connectionRequestReceived[account] = nil
end
-- ============================================================================
-- Helper Functions
-- ============================================================================
function private.SendNewAccountWhoAmI()
if not private.newCharacter then
Delay.Cancel("syncNewAccount")
elseif not C_FriendList.GetFriendInfo(private.newCharacter) then
Log.Info("Waiting for friends list to update")
elseif not private.IsOnline(private.newCharacter) then
Delay.Cancel("syncNewAccount")
private.newCharacter = nil
private.newAccount = nil
private.newSyncAcked = nil
Log.Err("New player went offline")
else
Comm.SendData(Constants.DATA_TYPES.WHOAMI_ACCOUNT, private.newCharacter)
Log.Info("Sent WHOAMI_ACCOUNT")
end
end
function private.CheckNewAccountStatus()
if not private.newCharacter or not private.newAccount or not private.newSyncAcked then
return
end
Log.Info("New sync character: '%s' '%s'", private.newCharacter, private.newAccount)
-- the other account ACK'd so setup a connection
Settings.NewSyncCharacter(private.newAccount, private.newCharacter)
-- call the callbacks for this new account
for _, callback in ipairs(private.connectionChangedCallbacks) do
callback(private.newAccount, private.newCharacter, nil)
end
private.newCharacter = nil
private.newAccount = nil
private.newSyncAcked = nil
end
function private.GetTargetCharacter(account)
local tempTbl = TempTable.Acquire()
for _, character in Settings.CharacterByAccountFactionrealmIterator(account) do
tinsert(tempTbl, character)
end
-- find the player to connect to without adding to the friends list
for _, player in ipairs(tempTbl) do
if private.IsOnline(player, true) then
TempTable.Release(tempTbl)
return player
end
end
-- if we failed, try again with adding to friends list
for _, player in ipairs(tempTbl) do
if private.IsOnline(player) then
TempTable.Release(tempTbl)
return player
end
end
TempTable.Release(tempTbl)
end
function private.IsOnline(target, noAdd)
local info = C_FriendList.GetFriendInfo(target)
if not info and not noAdd and not private.invalidCharacters[strlower(target)] and C_FriendList.GetNumFriends() ~= 50 then
-- add them as a friend
C_FriendList.AddFriend(target)
private.RequestFriendsInfo()
tinsert(private.addedFriends, target)
info = C_FriendList.GetFriendInfo(target)
end
return info and info.connected or false
end
function private.ChatMsgSystemEventHandler(_, msg)
if #private.addedFriends == 0 then
return
end
if msg == ERR_FRIEND_NOT_FOUND then
if #private.addedFriends > 0 then
private.invalidCharacters[strlower(tremove(private.addedFriends, 1))] = true
end
else
for i, v in ipairs(private.addedFriends) do
if format(ERR_FRIEND_ADDED_S, v) == msg then
tremove(private.addedFriends, i)
private.invalidCharacters[strlower(v)] = true
end
end
end
end

View File

@@ -0,0 +1,29 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Constants = TSM.Init("Service.SyncClasses.Constants")
Constants.VERSION = 11
Constants.DATA_TYPES = {
-- new connection types (40-49)
WHOAMI_ACCOUNT = strchar(40),
WHOAMI_ACK = strchar(41),
-- connection status types (50-69)
CONNECTION_REQUEST = strchar(50),
CONNECTION_REQUEST_ACK = strchar(51),
DISCONNECT = strchar(52),
HEARTBEAT = strchar(53),
-- data mirroring types (70-99)
CHARACTER_HASHES_BROADCAST = strchar(70),
CHARACTER_SETTING_HASHES_REQUEST = strchar(71),
CHARACTER_SETTING_HASHES_RESPONSE = strchar(72),
CHARACTER_SETTING_DATA_REQUEST = strchar(73),
CHARACTER_SETTING_DATA_RESPONSE = strchar(74),
-- RPC types (100-109)
RPC_CALL = strchar(100),
RPC_RETURN = strchar(101),
RPC_PREAMBLE = strchar(102),
}

View File

@@ -0,0 +1,268 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Mirror = TSM.Init("Service.SyncClasses.Mirror")
local Delay = TSM.Include("Util.Delay")
local TempTable = TSM.Include("Util.TempTable")
local Math = TSM.Include("Util.Math")
local Log = TSM.Include("Util.Log")
local Settings = TSM.Include("Service.Settings")
local Constants = TSM.Include("Service.SyncClasses.Constants")
local Comm = TSM.Include("Service.SyncClasses.Comm")
local Connection = TSM.Include("Service.SyncClasses.Connection")
local private = {
numConnected = 0,
accountStatus = {},
callbacks = {},
}
local BROADCAST_INTERVAL = 3
-- ============================================================================
-- Module Loading
-- ============================================================================
Mirror:OnModuleLoad(function()
Connection.RegisterConnectionChangedCallback(private.ConnectionChangedHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CHARACTER_HASHES_BROADCAST, private.CharacterHashesBroadcastHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_REQUEST, private.CharacterSettingHashesRequestHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_RESPONSE, private.CharacterSettingHashesResponseHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CHARACTER_SETTING_DATA_REQUEST, private.CharacterSettingDataRequestHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CHARACTER_SETTING_DATA_RESPONSE, private.CharacterSettingDataResponseHandler)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Mirror.GetStatus(account)
local status = private.accountStatus[account]
if not status then
return false, false
elseif status == "UPDATING" then
return true, false
elseif status == "SYNCED" then
return true, true
else
error("Invalid status: "..tostring(status))
end
end
function Mirror.RegisterCallback(callback)
tinsert(private.callbacks, callback)
end
-- ============================================================================
-- Connection Callback Handlers
-- ============================================================================
function private.ConnectionChangedHandler(account, player, connected)
if connected == nil then
-- new account, but not yet connected
return
end
if connected then
Log.Info("Connected to %s (%s)", account, player)
else
Log.Info("Disconnected from %s (%s)", account, player)
end
private.numConnected = private.numConnected + (connected and 1 or -1)
assert(private.numConnected >= 0)
if connected then
private.accountStatus[account] = "UPDATING"
Delay.AfterTime("mirrorCharacterHashes", 0, private.SendCharacterHashes, BROADCAST_INTERVAL)
else
private.accountStatus[account] = nil
if private.numConnected == 0 then
Delay.Cancel("mirrorCharacterHashes")
end
end
end
-- ============================================================================
-- Delay-Based Last Update Send Function
-- ============================================================================
function private.SendCharacterHashes()
assert(private.numConnected > 0)
-- calculate the hashes of the sync settings for all characters on this account
local hashes = TempTable.Acquire()
for _, character in Settings.CharacterByAccountFactionrealmIterator() do
hashes[character] = private.CalculateCharacterHash(character)
end
-- send the hashes to all connected accounts
for _, character in Connection.ConnectedAccountIterator() do
Comm.SendData(Constants.DATA_TYPES.CHARACTER_HASHES_BROADCAST, character, hashes)
end
TempTable.Release(hashes)
end
-- ============================================================================
-- Message Handlers
-- ============================================================================
function private.CharacterHashesBroadcastHandler(dataType, sourceAccount, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.CHARACTER_HASHES_BROADCAST)
if not Connection.IsCharacterConnected(sourcePlayer) then
-- we're not connected to this player
Log.Warn("Got CHARACTER_HASHES_BROADCAST for player which isn't connected")
return
end
local didChange = false
for _, character in Settings.CharacterByAccountFactionrealmIterator(sourceAccount) do
if not data[character] then
-- this character doesn't exist anymore, so remove it
Log.Info("Removed character: '%s'", character)
Settings.RemoveSyncCharacter(character)
didChange = true
end
end
for character, hash in pairs(data) do
if not Settings.GetCharacterSyncAccountKey(character) then
-- this is a new character, so add it to our DB
Log.Info("New character: '%s' '%s'", character, sourceAccount)
Settings.NewSyncCharacter(sourceAccount, character)
didChange = true
end
if hash ~= private.CalculateCharacterHash(character) then
-- this character's data has changed so request a hash of each of the keys
Log.Info("Character data has changed: '%s'", character)
Comm.SendData(Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_REQUEST, sourcePlayer, character)
didChange = true
end
end
if didChange then
private.accountStatus[sourceAccount] = "UPDATING"
else
private.accountStatus[sourceAccount] = "SYNCED"
end
end
function private.CharacterSettingHashesRequestHandler(dataType, sourceAccount, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_REQUEST)
if not Connection.IsCharacterConnected(sourcePlayer) then
-- we're not connected to this player
Log.Warn("Got CHARACTER_HASHES_BROADCAST for player which isn't connected")
return
elseif Settings.GetCharacterSyncAccountKey(data) ~= Settings.GetCurrentSyncAccountKey() then
-- we don't own this character
Log.Err("Request for character we don't own ('%s', '%s')", tostring(data), tostring(Settings.GetCharacterSyncAccountKey(data)))
return
end
Log.Info("CHARACTER_SETTING_HASHES_REQUEST (%s)", data)
local responseData = TempTable.Acquire()
responseData._character = data
for _, namespace, settingKey in Settings.SyncSettingIterator() do
responseData[namespace.."."..settingKey] = private.CalculateCharacterSettingHash(data, namespace, settingKey)
end
Comm.SendData(Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_RESPONSE, sourcePlayer, responseData)
TempTable.Release(responseData)
end
function private.CharacterSettingHashesResponseHandler(dataType, sourceAccount, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_RESPONSE)
if not Connection.IsCharacterConnected(sourcePlayer) then
-- we're not connected to this player
Log.Warn("Got CHARACTER_HASHES_BROADCAST for player which isn't connected")
return
end
local character = data._character
data._character = nil
Log.Info("CHARACTER_SETTING_HASHES_RESPONSE (%s)", character)
for key, hash in pairs(data) do
local namespace, settingKey = strsplit(".", key)
if private.CalculateCharacterSettingHash(character, namespace, settingKey) ~= hash then
-- the settings data for key changed, so request the latest data for it
Log.Info("Setting data has changed: '%s', '%s'", character, key)
Comm.SendData(Constants.DATA_TYPES.CHARACTER_SETTING_DATA_REQUEST, sourcePlayer, character.."."..key)
end
end
end
function private.CharacterSettingDataRequestHandler(dataType, sourceAccount, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.CHARACTER_SETTING_DATA_REQUEST)
local character, namespace, settingKey = strsplit(".", data)
if not Connection.IsCharacterConnected(sourcePlayer) then
-- we're not connected to this player
Log.Warn("Got CHARACTER_HASHES_BROADCAST for player which isn't connected")
return
elseif Settings.GetCharacterSyncAccountKey(character) ~= Settings.GetCurrentSyncAccountKey() then
-- we don't own this character
Log.Err("Request for character we don't own ('%s', '%s')", tostring(character), tostring(Settings.GetCharacterSyncAccountKey(character)))
return
end
Log.Info("CHARACTER_SETTING_DATA_REQUEST (%s,%s,%s)", character, namespace, settingKey)
local responseData = TempTable.Acquire()
responseData.character = character
responseData.namespace = namespace
responseData.settingKey = settingKey
responseData.data = Settings.Get("sync", Settings.GetSyncScopeKeyByCharacter(character), namespace, settingKey)
Comm.SendData(Constants.DATA_TYPES.CHARACTER_SETTING_DATA_RESPONSE, sourcePlayer, responseData)
TempTable.Release(responseData)
end
function private.CharacterSettingDataResponseHandler(dataType, sourceAccount, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.CHARACTER_SETTING_DATA_RESPONSE)
if not Connection.IsCharacterConnected(sourcePlayer) then
-- we're not connected to this player
Log.Warn("Got CHARACTER_HASHES_BROADCAST for player which isn't connected")
return
end
local dataValueType = type(data.data)
Log.Info("CHARACTER_SETTING_DATA_RESPONSE (%s,%s,%s,%s,%s)", data.character, data.namespace, data.settingKey, dataValueType, (dataValueType == "string" or dataValueType == "table") and #dataValueType or "-")
if dataValueType == "table" then
local tbl = Settings.Get("sync", Settings.GetSyncScopeKeyByCharacter(data.character), data.namespace, data.settingKey)
wipe(tbl)
for i, v in pairs(data.data) do
tbl[i] = v
end
else
Settings.Set("sync", Settings.GetSyncScopeKeyByCharacter(data.character), data.namespace, data.settingKey, data.data)
end
for _, callback in ipairs(private.callbacks) do
callback()
end
end
-- ============================================================================
-- Helper Functions
-- ============================================================================
function private.CalculateCharacterHash(character)
local hash = nil
local settingKeys = TempTable.Acquire()
for _, namespace, settingKey in Settings.SyncSettingIterator() do
tinsert(settingKeys, strjoin(".", namespace, settingKey))
end
sort(settingKeys)
for _, key in ipairs(settingKeys) do
hash = Math.CalculateHash(key, hash)
local namespace, settingKey = strsplit(".", key)
local settingValue = Settings.Get("sync", Settings.GetSyncScopeKeyByCharacter(character), namespace, settingKey)
hash = Math.CalculateHash(settingValue, hash)
end
assert(hash)
TempTable.Release(settingKeys)
return hash
end
function private.CalculateCharacterSettingHash(character, namespace, settingKey)
return Math.CalculateHash(Settings.Get("sync", Settings.GetSyncScopeKeyByCharacter(character), namespace, settingKey))
end

View File

@@ -0,0 +1,177 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local RPC = TSM.Init("Service.SyncClasses.RPC")
local Delay = TSM.Include("Util.Delay")
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local Constants = TSM.Include("Service.SyncClasses.Constants")
local Comm = TSM.Include("Service.SyncClasses.Comm")
local Connection = TSM.Include("Service.SyncClasses.Connection")
local private = {
rpcFunctions = {},
pendingRPC = {},
rpcSeqNum = 0,
}
local RPC_EXTRA_TIMEOUT = 15
local CALLBACK_TIME_WARNING_THRESHOLD_MS = 20
-- ============================================================================
-- Module Loading
-- ============================================================================
RPC:OnModuleLoad(function()
Comm.RegisterHandler(Constants.DATA_TYPES.RPC_CALL, private.HandleCall)
Comm.RegisterHandler(Constants.DATA_TYPES.RPC_RETURN, private.HandleReturn)
Comm.RegisterHandler(Constants.DATA_TYPES.RPC_PREAMBLE, private.HandlePreamble)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function RPC.Register(name, func)
assert(name)
private.rpcFunctions[name] = func
end
function RPC.Call(name, targetPlayer, handler, ...)
assert(targetPlayer)
if not Connection.IsCharacterConnected(targetPlayer) then
return false
end
assert(private.rpcFunctions[name], "Cannot call an RPC which is not also registered locally.")
private.rpcSeqNum = private.rpcSeqNum + 1
local requestData = TempTable.Acquire()
requestData.name = name
requestData.args = TempTable.Acquire(...)
requestData.seq = private.rpcSeqNum
local numBytes = Comm.SendData(Constants.DATA_TYPES.RPC_CALL, targetPlayer, requestData)
TempTable.Release(requestData.args)
TempTable.Release(requestData)
local context = TempTable.Acquire()
context.name = name
context.handler = handler
context.timeoutTime = time() + RPC_EXTRA_TIMEOUT + private.EstimateTransferTime(numBytes)
private.pendingRPC[private.rpcSeqNum] = context
Delay.AfterTime("SYNC_PENDING_RPC", 1, private.HandlePendingRPC)
return true, (context.timeoutTime - time()) * 2 / 3
end
function RPC.Cancel(name, handler)
for seq, info in pairs(private.pendingRPC) do
if info.name == name and info.handler == handler then
TempTable.Release(info)
private.pendingRPC[seq] = nil
return
end
end
end
-- ============================================================================
-- Message Handlers
-- ============================================================================
function private.HandleCall(dataType, _, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.RPC_CALL)
if type(data) ~= "table" or type(data.name) ~= "string" or type(data.seq) ~= "number" or type(data.args) ~= "table" then
return
end
if not private.rpcFunctions[data.name] then
return
end
local responseData = TempTable.Acquire()
local startTime = debugprofilestop()
responseData.result = TempTable.Acquire(private.rpcFunctions[data.name](unpack(data.args)))
local timeTaken = debugprofilestop() - startTime
if timeTaken > CALLBACK_TIME_WARNING_THRESHOLD_MS then
Log.Warn("RPC (%s) took %0.2fms", tostring(data.name), timeTaken)
end
responseData.seq = data.seq
local numBytes = Comm.SendData(Constants.DATA_TYPES.RPC_RETURN, sourcePlayer, responseData)
TempTable.Release(responseData.result)
TempTable.Release(responseData)
local transferTime = private.EstimateTransferTime(numBytes)
if transferTime > 1 then
-- We sent more than 1 second worth of data back, so send a preamble to allow the source to adjust its timeout accordingly.
local preambleData = TempTable.Acquire()
preambleData.transferTime = transferTime
preambleData.seq = data.seq
Comm.SendData(Constants.DATA_TYPES.RPC_PREAMBLE, sourcePlayer, preambleData)
TempTable.Release(preambleData)
end
end
function private.HandleReturn(dataType, _, _, data)
assert(dataType == Constants.DATA_TYPES.RPC_RETURN)
if type(data.seq) ~= "number" or type(data.result) ~= "table" then
return
elseif not private.pendingRPC[data.seq] then
return
end
local startTime = debugprofilestop()
private.pendingRPC[data.seq].handler(unpack(data.result))
local timeTaken = debugprofilestop() - startTime
if timeTaken > CALLBACK_TIME_WARNING_THRESHOLD_MS then
Log.Warn("RPC (%s) result handler took %0.2fms", tostring(private.pendingRPC[data.seq].name), timeTaken)
end
TempTable.Release(private.pendingRPC[data.seq])
private.pendingRPC[data.seq] = nil
end
function private.HandlePreamble(dataType, _, _, data)
assert(dataType == Constants.DATA_TYPES.RPC_PREAMBLE)
if type(data.seq) ~= "number" or type(data.transferTime) ~= "number" then
return
elseif not private.pendingRPC[data.seq] then
return
end
-- extend the timeout
private.pendingRPC[data.seq].timeoutTime = time() + RPC_EXTRA_TIMEOUT + data.transferTime
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.EstimateTransferTime(numBytes)
return ceil(numBytes / (ChatThrottleLib.MAX_CPS / 2))
end
function private.HandlePendingRPC()
if not next(private.pendingRPC) then
return
end
local timedOut = TempTable.Acquire()
for seq, info in pairs(private.pendingRPC) do
if time() > info.timeoutTime then
tinsert(timedOut, seq)
end
end
for _, seq in ipairs(timedOut) do
local info = private.pendingRPC[seq]
Log.Warn("RPC timed out (%s)", info.name)
info.handler()
TempTable.Release(info)
private.pendingRPC[seq] = nil
end
TempTable.Release(timedOut)
end

View File

@@ -0,0 +1,839 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Threading Functions
-- @module Threading
local _, TSM = ...
local Threading = TSM.Init("Service.Threading")
local Debug = TSM.Include("Util.Debug")
local Math = TSM.Include("Util.Math")
local TempTable = TSM.Include("Util.TempTable")
local Vararg = TSM.Include("Util.Vararg")
local Log = TSM.Include("Util.Log")
local ErrorHandler = TSM.Include("Service.ErrorHandler")
local Thread = TSM.Include("LibTSMClass").DefineClass("Thread")
local private = {
threads = {},
queue = {},
runningThread = nil,
schedulerFrame = nil,
}
local MAX_TIME_USAGE_RATIO = 0.25
local EXCESSIVE_TIME_USED_RATIO = 1.2
local EXCESSIVE_TIME_LOG_THRESHOLD_MS = 100
local MAX_QUANTUM_MS = 10
local SEND_MSG_SYNC_TIMEOUT_MS = 3000
local YIELD_VALUE_START = {}
local YIELD_VALUE = {}
local SCHEDULER_TIME_WARNING_THRESHOLD_MS = 100
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Create a new thread.
-- @tparam string name The name of the thread (for debugging purposes)
-- @tparam function func The thread's main function
-- @treturn string The thread id
function Threading.New(name, func)
assert(name and func)
local thread = Thread(name, func)
local threadId = strjoin("-", tostringall(thread, func))
private.threads[threadId] = thread
return threadId
end
--- Set the callback for a thread.
-- The callback is called when a thread finishes running and is passed all values returned by the thread's main function.
-- @tparam string threadId The thread id
-- @tparam function callback The callback function
function Threading.SetCallback(threadId, callback)
private.threads[threadId]:_SetCallback(callback)
end
--- Start a thread.
-- The thread will not actually be run until the next run of the scheduler (next frame).
-- @tparam string threadId The thread id
-- @tparam vararg ... Arguments to pass to the thread's main function
function Threading.Start(threadId, ...)
local thread = private.threads[threadId]
assert(not thread:_IsAlive())
-- make sure the scheduler is running
private.StartScheduler()
thread:_Start(...)
end
--- Send a message to a thread.
-- @tparam string threadId The thread id
-- @tparam vararg ... The contents of the message
function Threading.SendMessage(threadId, ...)
local thread = private.threads[threadId]
assert(thread:_IsAlive())
tinsert(thread._messages, TempTable.Acquire(...))
end
--- Send a synchronous message to a thread.
-- The current execution context will be blocked until the message is delivered.
-- @tparam string threadId The thread id
-- @tparam vararg ... The contents of the message
function Threading.SendSyncMessage(threadId, ...)
if Threading.IsThreadContext() then
-- we can't (sanely) run a thread from within a thread context, so we'll yield from the current thread first
private.runningThread:_SendSyncMessage(threadId, ...)
else
local errMsg = private.threads[threadId]:_HandleSyncMessage(...)
if errMsg then
error(errMsg)
end
end
end
--- Kill a thread.
-- @tparam string threadId The thread id
function Threading.Kill(threadId)
local thread = private.threads[threadId]
if thread:_IsAlive() then
thread:_Exit()
end
end
--- Check if we're currently in a thread context.
-- @treturn boolean Whether or not we're currently in a thread context
function Threading.IsThreadContext()
return private.runningThread ~= nil
end
--- Get the name of the current thread.
-- @treturn ?string The name of the currently-running thread or nil if no thread is running
function Threading.GetCurrentThreadName()
return private.runningThread and private.runningThread._name or nil
end
--- Check if the current thread has any pending messages.
-- This must be called from a thread context.
-- @treturn boolean Whether or not this thread has any pending messages
function Threading.HasPendingMessage()
return private.runningThread:_HasPendingMessage()
end
--- Receive the next message.
-- This must be called from a thread context.
-- @return The context of the message
function Threading.ReceiveMessage()
return private.runningThread:_ReceiveMessage()
end
--- Performs a yield.
-- This must be called from a thread context. This function should be called regularly by threads to allow them the
-- scheduler to switch to another thread if this thread's quantum is up. If this thread has not exceeded its quantum,
-- this function will return right away.
-- @tparam[opt=false] boolean force If true, forces this thread to yield, regardless of whether or not it needs to
function Threading.Yield(force)
private.runningThread:_Yield(force)
end
--- Sleep the thread.
-- This must be called from a thread context.
-- @tparam number seconds The number of seconds to sleep for (may be a deciaml number)
function Threading.Sleep(seconds)
private.runningThread:_Sleep(seconds)
end
--- Wait for a WoW event.
-- This must be called from a thread context.
-- @tparam string event The WoW event to wait for
function Threading.WaitForEvent(...)
return private.runningThread:_WaitForEvent(...)
end
--- Wait for a function.
-- This must be called from a thread context. It will block the thread until the specified function returns a true value.
-- @tparam function func The function to wait for
-- @tparam vararg ... Additional arguments to pass to the function
function Threading.WaitForFunction(func, ...)
return private.runningThread:_WaitForFunction(func, ...)
end
--- Wait for a future.
-- This must be called from a thread context. It will block the thread until the specified future is done.
-- @tparam Future future The future to wait for
function Threading.WaitForFuture(future)
return private.runningThread:_WaitForFuture(future)
end
--- Acquire a temp table.
-- This must be called from a thread context. Any time a thread needs to maintain a temp table across a potential yield,
-- it should use this API. This API will release the temp tables in the case that the thread gets killed.
-- @see TempTable.Acquire
-- @tparam vararg ... Values to insert into the temp table
function Threading.AcquireSafeTempTable(...)
return private.runningThread:_AcquireSafeTempTable(...)
end
--- Release a temp table.
-- This must be called from a thread context. This is used to release temp tables acquired with
-- @{Threading.AcquireSafeTempTable}.
-- @see TempTable.Release
-- @tparam table tbl The temp table to release
function Threading.ReleaseSafeTempTable(tbl)
return private.runningThread:_ReleaseSafeTempTable(tbl)
end
--- Release a temp table and returns its contents.
-- This must be called from a thread context. This is used to release and unpack temp tables acquired with
-- @{Threading.AcquireSafeTempTable}.
-- @see TempTable.UnpackAndRelease
-- @tparam table tbl The temp table to release and unpack
-- @return The contents of the temp table
function Threading.UnpackAndReleaseSafeTempTable(tbl)
return private.runningThread:_UnpackAndReleaseSafeTempTable(tbl)
end
--- Assign ownership of a database query to the thread so that if the thread is killed it'll get released safely.
-- This must be called from a thread context.
-- @tparam DatabaseQuery query The query object
function Threading.GuardDatabaseQuery(query)
return private.runningThread:_GuardDatabaseQuery(query)
end
--- Removes ownership of a database query from the thread.
-- This must be called from a thread context.
-- @tparam DatabaseQuery query The query object
function Threading.UnguardDatabaseQuery(query)
return private.runningThread:_UnguardDatabaseQuery(query)
end
function Threading.GetDebugStr()
local lines = {}
for _, thread in pairs(private.threads) do
if thread:_IsAlive() then
local name, stateStr, timeStr, createStr, startStr, backtrace = thread:_GetDebugInfo()
tinsert(lines, " "..name)
tinsert(lines, " "..stateStr)
tinsert(lines, " "..timeStr)
tinsert(lines, " "..createStr)
tinsert(lines, " "..startStr)
if #backtrace > 0 then
tinsert(lines, " Backtrace:")
for _, line in ipairs(backtrace) do
tinsert(lines, " "..line)
end
end
end
end
return table.concat(lines, "\n")
end
-- ============================================================================
-- Thread Class - General Methods
-- ============================================================================
function Thread.__init(self, name, func)
-- core fields
self._func = func
self._co = nil
self._state = "DEAD"
self._endTime = nil
self._sleepTime = nil
self._eventNames = {}
self._eventArgs = nil
self._waitFunction = nil
self._waitFunctionArgs = nil
self._waitFunctionResult = nil
self._waitFuture = nil
self._waitFutureResult = nil
self._waitFutureDone = false
self._syncMessage = nil
self._syncMessageDest = nil
self._messages = {}
self._callback = nil
self._returnValue = nil
self._safeTempTables = {}
self._guardedQueries = {}
-- debug fields
self._startTime = 0
self._cpuTimeUsed = 0
self._realTimeUsed = 0
self._name = name or tostring(self)
self._createCaller = Debug.GetStackLevelLocation(3) or "?"
self._startCaller = nil
end
function Thread._Start(self, ...)
self._co = coroutine.create(self._Main)
self._state = "READY"
self._endTime = 0
self._sleepTime = nil
wipe(self._eventNames)
self._eventArgs = nil
self._waitFunction = nil
self._waitFunctionArgs = nil
self._waitFunctionResult = nil
self._waitFuture = nil
self._waitFutureResult = nil
self._waitFutureDone = false
self._syncMessage = nil
self._syncMessageDest = nil
assert(not next(self._messages))
assert(not next(self._safeTempTables))
assert(not next(self._guardedQueries))
self._startTime = 0
self._cpuTimeUsed = 0
self._realTimeUsed = 0
self._startCaller = self._startCaller or Debug.GetStackLevelLocation(3)
-- run the thread once (will yield right away) to pass in self and the arguments
local noErr, retValue = coroutine.resume(self._co, self, ...)
assert(noErr and retValue == YIELD_VALUE_START)
end
function Thread._SetCallback(self, callback)
self._callback = callback
end
function Thread._IsAlive(self)
return self._state ~= "DEAD"
end
function Thread._ToLogStr(self)
if self._startTime then
self._realTimeUsed = debugprofilestop() - self._startTime
local pctStr = format("%.1f%%", Math.Round(self._cpuTimeUsed / self._realTimeUsed, 0.001) * 100)
return format("%s [%s,%s]", self._name, self._state, pctStr)
else
return format("%s [%s]", self._name, self._state)
end
end
function Thread._Cleanup(self)
for _, msg in ipairs(self._messages) do
TempTable.Release(msg)
end
wipe(self._messages)
for tbl in pairs(self._safeTempTables) do
TempTable.Release(tbl)
end
wipe(self._safeTempTables)
for query in pairs(self._guardedQueries) do
query:Release(true)
end
wipe(self._guardedQueries)
self._waitFunction = nil
if self._waitFunctionArgs then
TempTable.Release(self._waitFunctionArgs)
self._waitFunctionArgs = nil
end
if self._waitFuture then
self._waitFuture:Cancel()
self._waitFuture = nil
self._waitFutureResult = nil
self._waitFutureDone = false
end
if self._waitFunctionResult then
TempTable.Release(self._waitFunctionResult)
self._waitFunctionResult = nil
end
if self._syncMessage then
TempTable.Release(self._syncMessage)
self._syncMessage = nil
self._syncMessageDest = nil
end
end
function Thread._GetDebugInfo(self)
local stateStr = nil
if self._state == "SLEEPING" then
stateStr = format("Sleeping for %d seconds", Math.Round(self._sleepTime, 0.001))
elseif self._state == "WAITING_FOR_MSG" then
if #self._messages > 0 then
stateStr = "Got message"
else
stateStr = "Waiting for message"
end
elseif self._state == "WAITING_FOR_EVENT" then
if next(self._eventNames) then
local eventList = {}
for event in pairs(self._eventNames) do
tinsert(eventList, event)
end
stateStr = format("Waiting for %s", table.concat(eventList, "|"))
else
stateStr = format("Got %s", self._eventArgs[1])
end
elseif self._state == "WAITING_FOR_FUNCTION" then
local functionName = nil
-- look up to 2 levels deep in the globals table for the name of this function
for k, v in pairs(_G) do
if type(v) == "table" then
for k2, v2 in pairs(v) do
if v2 == self._waitFunction then
functionName = tostring(k).."."..tostring(k2)
break
end
end
if functionName then
break
end
elseif v == self._waitFunction then
functionName = tostring(k)
break
end
end
stateStr = format("Waiting for %s", functionName or tostring(self._waitFunction))
elseif self._state == "WAITING_FOR_FUTURE" then
stateStr = format("Waiting for future (%s)", self._waitFuture:GetName())
elseif self._state == "FORCED_YIELD" then
stateStr = "Forced yield"
elseif self._state == "SENDING_SYNC_MESSAGE" then
stateStr = format("Sending sync message to %s", self._syncMessageDest and private.threads[self._syncMessageDest]._name or "?")
elseif self._state == "RUNNING" then
stateStr = "Running"
elseif self._state == "DEAD" then
stateStr = "Dead"
elseif self._state == "READY" then
stateStr = "Ready"
else
error("Invalid thread state: "..tostring(self._state))
end
if #self._messages > 0 then
stateStr = format("%s (%d messages)", stateStr, #self._messages)
end
local timeStr = "<Not Started>"
if self._startTime then
local wallTime = debugprofilestop() - self._startTime
local cpuTime = self._cpuTimeUsed
timeStr = format("Running for %.1f seconds (CPU: %dms, %.2f%%)", wallTime / 1000, cpuTime, (cpuTime / wallTime) * 100)
end
local createStr = "Created @"..self._createCaller
local startStr = "Started @"..(self._startCaller or "<Not Started>")
local backtrace = {}
local level = 2
local line = Debug.GetStackLevelLocation(level, self._co)
while line do
tinsert(backtrace, line)
level = level + 1
line = Debug.GetStackLevelLocation(level, self._co)
end
return self._name, stateStr, timeStr, createStr, startStr, backtrace
end
-- ============================================================================
-- Thread Class - Scheduler Helper Methods
-- ============================================================================
function Thread._CanRun(self)
return self._state == "READY"
end
function Thread._Run(self, quantum)
assert(not Threading.IsThreadContext())
if not self:_CanRun() then
return 0
end
private.runningThread = self
self._state = "RUNNING"
local startTime = debugprofilestop()
self._endTime = startTime + quantum
local noErr, returnVal = coroutine.resume(self._co)
local elapsedTime = debugprofilestop() - startTime
private.runningThread = nil
assert(not noErr or returnVal == YIELD_VALUE)
if noErr and self._state == "SENDING_SYNC_MESSAGE" then
-- yielded to send a sync message to another thread
local destThread = private.threads[self._syncMessageDest]
local msg = self._syncMessage
self._syncMessage = nil
self._syncMessageDest = nil
local errMsg = destThread:_HandleSyncMessage(TempTable.UnpackAndRelease(msg))
if errMsg then
noErr = false
returnVal = errMsg
elseif self._state == "SENDING_SYNC_MESSAGE" then
self._state = "READY"
end
end
if not noErr then
returnVal = returnVal or "UNKNOWN ERROR"
ErrorHandler.ShowForThread(returnVal, self._co)
self._state = "DEAD"
end
if self._state == "DEAD" then
self:_Cleanup()
local returnValue = self._returnValue
self._returnValue = nil
if self._callback and returnValue then
self._callback(TempTable.UnpackAndRelease(returnValue))
elseif returnValue then
TempTable.Release(returnValue)
end
end
return elapsedTime
end
function Thread._UpdateState(self, elapsed)
-- check what the thread state is
if self._state == "SLEEPING" then
self._sleepTime = self._sleepTime - elapsed
if self._sleepTime <= 0 then
self._sleepTime = nil
self._state = "READY"
end
elseif self._state == "WAITING_FOR_MSG" then
if #self._messages > 0 then
self._state = "READY"
end
elseif self._state == "WAITING_FOR_EVENT" then
assert(self._eventNames or self._eventArgs)
if self._eventArgs then
self._state = "READY"
end
elseif self._state == "WAITING_FOR_FUNCTION" then
assert(self._waitFunction, "Waiting for function without waitFunction set")
local result = TempTable.Acquire(self._waitFunction(unpack(self._waitFunctionArgs)))
if result[1] then
self._waitFunctionResult = result
self._state = "READY"
else
TempTable.Release(result)
end
elseif self._state == "WAITING_FOR_FUTURE" then
if self._waitFutureDone then
assert(not self._waitFuture)
self._waitFutureDone = false
self._state = "READY"
else
assert(self._waitFuture)
end
elseif self._state == "FORCED_YIELD" then
self._state = "READY"
elseif self._state == "RUNNING" then
-- this shouldn't happen, so just kill this thread
self:_Exit()
elseif self._state == "DEAD" then
-- pass
elseif self._state == "READY" then
-- pass
else
error("Invalid thread state: "..tostring(self._state))
end
end
function Thread._ProcessEvent(self, event, ...)
if self._state ~= "WAITING_FOR_EVENT" then
return
end
assert(next(self._eventNames) or self._eventArgs)
if not self._eventNames[event] then
return
end
wipe(self._eventNames) -- only trigger the event once then clear all
self._eventArgs = TempTable.Acquire(event, ...)
end
function Thread._OnFutureDone(self, future)
if self._state ~= "WAITING_FOR_FUTURE" then
return
end
assert(self._waitFuture or self._waitFutureDone)
if future ~= self._waitFuture then
return
end
self._waitFutureDone = true
self._waitFuture = nil
self._waitFutureResult = future:GetValue()
self:_UpdateState(0)
self:_Run(0)
end
function Thread._HandleSyncMessage(self, ...)
assert(not Threading.IsThreadContext())
local msg = TempTable.Acquire(...)
tinsert(self._messages, 1, msg) -- this message should be received first
-- run the thread for up to 3 seconds to get it to process the sync message
local startTime = debugprofilestop()
while self._messages[1] == msg do
if debugprofilestop() - startTime > SEND_MSG_SYNC_TIMEOUT_MS or not self:_IsAlive() then
-- want to error from the sending context, so just return the error
return format("ERROR: A sync message was not able to be delivered! (%s)", self._name)
end
assert(self._state ~= "SENDING_SYNC_MESSAGE", "Circular sync message detected")
if self._state == "WAITING_FOR_MSG" then
self._state = "READY"
end
self:_Run(0)
end
end
-- ============================================================================
-- Thread Class - Thread Context Methods
-- ============================================================================
function Thread._Main(self, ...)
self._startTime = debugprofilestop()
coroutine.yield(YIELD_VALUE_START)
self._returnValue = TempTable.Acquire(self._func(...))
self:_Exit()
end
function Thread._Yield(self, force)
if force or self._state ~= "RUNNING" or debugprofilestop() > self._endTime then
-- only change the state if it's currently set to RUNNING
if self._state == "RUNNING" then
self._state = force and "FORCED_YIELD" or "READY"
end
coroutine.yield(YIELD_VALUE)
end
end
function Thread._Sleep(self, seconds)
self._state = "SLEEPING"
self._sleepTime = seconds
self:_Yield()
end
function Thread._HasPendingMessage(self)
return #self._messages > 0
end
function Thread._ReceiveMessage(self)
if #self._messages == 0 then
-- change the state if there's no messages ready
self._state = "WAITING_FOR_MSG"
elseif debugprofilestop() > self._endTime then
-- If we're about to yield, set the state to WAITING_FOR_MSG even if we have messages in the queue
-- to allow sync messages to be sent to us.
self._state = "WAITING_FOR_MSG"
end
self:_Yield()
return TempTable.UnpackAndRelease(tremove(self._messages, 1))
end
function Thread._SendSyncMessage(self, destThread, ...)
assert(destThread ~= self)
self._state = "SENDING_SYNC_MESSAGE"
self._syncMessageDest = destThread
self._syncMessage = TempTable.Acquire(...)
self:_Yield()
end
function Thread._WaitForEvent(self, ...)
self._state = "WAITING_FOR_EVENT"
self._eventArgs = nil
for _, event in Vararg.Iterator(...) do
self._eventNames[event] = true
private.schedulerFrame:RegisterEvent(event)
end
local st = GetTime()
self:_Yield()
local yieldTime = GetTime() - st
if yieldTime > 5 then
local firstEvent = ...
Log.Warn("Waited %d seconds for %s", yieldTime, firstEvent)
end
local result = self._eventArgs
self._eventArgs = nil
return TempTable.UnpackAndRelease(result)
end
function Thread._WaitForFunction(self, func, ...)
-- try the function once before yielding
local result = TempTable.Acquire(func(...))
if result[1] then
return TempTable.UnpackAndRelease(result)
end
TempTable.Release(result)
-- do the yield
self._state = "WAITING_FOR_FUNCTION"
self._waitFunction = func
self._waitFunctionArgs = TempTable.Acquire(...)
self:_Yield()
result = self._waitFunctionResult
self._waitFunction = nil
TempTable.Release(self._waitFunctionArgs)
self._waitFunctionArgs = nil
self._waitFunctionResult = nil
return TempTable.UnpackAndRelease(result)
end
function Thread._WaitForFuture(self, future)
-- try the future once before yielding
if future:IsDone() then
return future:GetValue()
end
-- register our OnDone handler and do the yield
future:SetScript("OnDone", private.OnFutureDone)
self._state = "WAITING_FOR_FUTURE"
self._waitFuture = future
self:_Yield()
local result = self._waitFutureResult
self._waitFutureResult = nil
return result
end
function Thread._AcquireSafeTempTable(self, ...)
local tbl = TempTable.Acquire(...)
assert(not self._safeTempTables[tbl])
self._safeTempTables[tbl] = true
return tbl
end
function Thread._ReleaseSafeTempTable(self, tbl)
assert(self._safeTempTables[tbl])
self._safeTempTables[tbl] = nil
return TempTable.Release(tbl)
end
function Thread._UnpackAndReleaseSafeTempTable(self, tbl)
assert(self._safeTempTables[tbl])
self._safeTempTables[tbl] = nil
return TempTable.UnpackAndRelease(tbl)
end
function Thread._GuardDatabaseQuery(self, query)
assert(not self._guardedQueries[query])
self._guardedQueries[query] = true
end
function Thread._UnguardDatabaseQuery(self, query)
assert(self._guardedQueries[query])
self._guardedQueries[query] = nil
end
function Thread._Exit(self)
assert(self:_IsAlive())
self._state = "DEAD"
self:_Cleanup()
Log.Info("Thread finished: %s", self:_ToLogStr())
if self == private.runningThread then
coroutine.yield(YIELD_VALUE)
error("Shouldn't get here")
elseif self._returnValue then
TempTable.Release(self._returnValue)
self._returnValue = nil
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.StartScheduler()
if private.schedulerFrame:IsVisible() then
return
end
Log.Info("Starting scheduler")
private.schedulerFrame:Show()
end
function private.RunScheduler(_, elapsed)
-- don't run any threads while in combat
if InCombatLockdown() then
return
end
local startTime = debugprofilestop()
local numReadyThreads = 0
wipe(private.queue)
-- go through all the threads, update their state, and add the ready ones into the queue
for _, thread in pairs(private.threads) do
thread:_UpdateState(elapsed)
if thread:_CanRun() then
numReadyThreads = numReadyThreads + 1
tinsert(private.queue, thread)
end
end
local remainingTime = min(elapsed * 1000 * MAX_TIME_USAGE_RATIO, MAX_QUANTUM_MS)
while remainingTime > 0.01 do
local ranThread = false
for i = #private.queue, 1, -1 do
local thread = private.queue[i]
if thread:_CanRun() then
local quantum = remainingTime / numReadyThreads
local elapsedTime = thread:_Run(quantum)
thread._cpuTimeUsed = thread._cpuTimeUsed + elapsedTime
remainingTime = remainingTime - min(elapsedTime, quantum)
-- any thread which ran excessively long should be ignored for future loops
if elapsedTime > EXCESSIVE_TIME_USED_RATIO * quantum and elapsedTime > quantum + 1 then
if elapsedTime > EXCESSIVE_TIME_LOG_THRESHOLD_MS then
local line = Debug.GetStackLevelLocation(2, thread._co)
Log.Warn("Thread %s ran too long (%.1f/%.1f): %s", thread._name, elapsedTime, quantum, line or "?")
end
tremove(private.queue, i)
end
ranThread = true
end
end
if not ranThread then
break
end
end
local hasAliveThread = false
for _, thread in pairs(private.threads) do
if thread:_IsAlive() then
hasAliveThread = true
break
end
end
if not hasAliveThread then
Log.Info("Stopping the scheduler")
private.schedulerFrame:Hide()
end
local timeTaken = debugprofilestop() - startTime
if timeTaken > SCHEDULER_TIME_WARNING_THRESHOLD_MS then
Log.Warn("Scheduler took %.2fms", timeTaken)
end
end
function private.ProcessEvent(self, event, ...)
local startTime = debugprofilestop()
for _, thread in pairs(private.threads) do
thread:_ProcessEvent(event, ...)
end
local timeTaken = debugprofilestop() - startTime
if timeTaken > SCHEDULER_TIME_WARNING_THRESHOLD_MS then
Log.Warn("Scheduler took %.2fms to process %s", timeTaken, tostring(event))
end
end
function private.OnFutureDone(future)
for _, thread in pairs(private.threads) do
thread:_OnFutureDone(future)
end
end
-- ============================================================================
-- Driver Frame
-- ============================================================================
do
private.schedulerFrame = CreateFrame("Frame")
private.schedulerFrame:Hide()
private.schedulerFrame:SetScript("OnUpdate", private.RunScheduler)
private.schedulerFrame:SetScript("OnEvent", private.ProcessEvent)
end

126
LibTSM/UI/Tooltip.lua Normal file
View File

@@ -0,0 +1,126 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- UI tooltip functions.
-- @module Tooltip
local _, TSM = ...
local Tooltip = TSM.Init("UI.Tooltip")
local ItemString = TSM.Include("Util.ItemString")
local Vararg = TSM.Include("Util.Vararg")
local Event = TSM.Include("Util.Event")
local ItemInfo = TSM.Include("Service.ItemInfo")
local private = {
currentParent = nil,
registeredEvent = false,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Shows a tooltip which is anchored to the frame.
-- @tparam Frame parent The parent and anchor frame for the tooltip
-- @tparam ?number|string|function data The tooltip information which can be either an itemId,
-- itemString, raw text string, or function which returns one of the other options
-- @tparam ?boolean noWrapping Disables wrapping of text lines
-- @tparam[opt=0] number xOffset An extra x offset to apply to the anchor of the tooltip
function Tooltip.Show(parent, data, noWrapping, xOffset)
if not data then
return
elseif type(data) == "function" then
local funcNoWrapping, funcXOffset = false, 0
data, funcNoWrapping, funcXOffset = data()
noWrapping = noWrapping or funcNoWrapping
xOffset = xOffset or funcXOffset
end
local showCompare = false
GameTooltip:SetOwner(parent, "ANCHOR_PRESERVE")
GameTooltip:ClearAllPoints()
GameTooltip:SetPoint("LEFT", parent, "RIGHT", xOffset or 0, 0)
if type(data) == "number" then
GameTooltip:SetHyperlink("item:"..data)
showCompare = true
elseif type(data) == "string" and strfind(data, "^craft:") then
data = strmatch(data, "craft:(%d+)")
GameTooltip:SetCraftSpell(tonumber(data))
elseif type(data) == "string" and strfind(data, "^enchant:") then
GameTooltip:SetHyperlink(data)
elseif type(data) == "string" and strfind(data, "^currency:") then
GameTooltip:SetCurrencyByID(strmatch(data, "currency:(%d+)"))
elseif type(data) == "string" and (strfind(data, "^\124c.+\124Hitem:") or ItemString.IsItem(data)) then
GameTooltip:SetHyperlink(ItemInfo.GetLink(data))
showCompare = true
elseif type(data) == "string" and (strfind(data, "^\124c.+\124Hbattlepet:") or ItemString.IsPet(data)) then
if strmatch(data, "p:") then
data = ItemInfo.GetLink(data)
end
local _, speciesID, level, breedQuality, maxHealth, power, speed = strsplit(":", data)
BattlePetToolTip_Show(tonumber(speciesID), tonumber(level) or 0, tonumber(breedQuality) or 0, tonumber(maxHealth) or 0, tonumber(power) or 0, tonumber(speed) or 0, gsub(gsub(data, "^(.*)%[", ""), "%](.*)$", ""))
else
for _, line in Vararg.Iterator(strsplit("\n", data)) do
local textLeft, textRight = strsplit(TSM.CONST.TOOLTIP_SEP, line)
if textRight then
GameTooltip:AddDoubleLine(textLeft, textRight, 1, 1, 1, 1, 1, 1)
else
GameTooltip:AddLine(textLeft, 1, 1, 1, not noWrapping)
end
end
end
GameTooltip:Show()
private.currentParent = parent
if showCompare then
assert(not private.registeredEvent)
private.registeredEvent = true
Event.Register("MODIFIER_STATE_CHANGED", private.UpdateCompareState)
private.UpdateCompareState()
end
end
--- Hides the current tooltip.
function Tooltip.Hide()
if private.registeredEvent then
Event.Unregister("MODIFIER_STATE_CHANGED", private.UpdateCompareState)
private.registeredEvent = false
end
private.currentParent = nil
GameTooltip:SetOwner(UIParent, "ANCHOR_PRESERVE")
GameTooltip:ClearAllPoints()
GameTooltip:SetPoint("CENTER")
GameTooltip:Hide()
if not TSM.IsWowClassic() then
BattlePetTooltip:ClearAllPoints()
BattlePetTooltip:SetPoint("CENTER")
BattlePetTooltip:Hide()
end
end
--- Checks if the tooltip is visible.
-- @tparam[opt=nil] table frame An optional parent frame to check against
-- @treturn boolean Whether or not the tooltip is visible
function Tooltip.IsVisible(frame)
if frame then
return private.currentParent == frame
else
return private.currentParent and true or false
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.UpdateCompareState()
if private.currentParent and GameTooltip:IsVisible() and IsShiftKeyDown() and not GameTooltip:IsEquippedItem() then
GameTooltip_ShowCompareItem(GameTooltip)
else
GameTooltip_HideShoppingTooltips(GameTooltip)
end
end

139
LibTSM/UI/UIElements.lua Normal file
View File

@@ -0,0 +1,139 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- UI element functions.
-- @module UIElements
local _, TSM = ...
local UIElements = TSM.Init("UI.UIElements")
local ObjectPool = TSM.Include("Util.ObjectPool")
local Table = TSM.Include("Util.Table")
local private = {
elementClasses = {},
objectPools = {},
namedElements = {},
activeFrameElementMap = {},
debugNameElementLookup = {},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Registers a UI Element subclass.
-- @tparam Element class The element subclass
function UIElements.Register(class)
private.elementClasses[class.__name] = class
end
--- Creates a new UI element.
-- @tparam string elementType The name of the element class
-- @tparam string id The id to assign to the element
-- @param ... Tags to set on the element
-- @treturn Element The created UI element object
function UIElements.New(elementType, id, ...)
return private.NewElementHelper(elementType, id, nil, ...)
end
--- Creates a new named UI element.
-- @tparam string elementType The name of the element class
-- @tparam string id The id to assign to the element
-- @tparam string name The global name of the element
-- @treturn Element The created UI element object
function UIElements.NewNamed(elementType, id, name, ...)
assert(name)
return private.NewElementHelper(elementType, id, name, ...)
end
--- Recycles a UI element.
-- @tparam Element element The UI element object
function UIElements.Recycle(element)
private.activeFrameElementMap[element:_GetBaseFrame()] = nil
if not Table.KeyByValue(private.namedElements, element) then
private.objectPools[element.__class]:Recycle(element)
end
end
--- Gets a UI element by its frame (for TSM's frame stack).
-- @tparam table frame The frame
-- @treturn ?Element The element or nil if the frame doesn't correspond to an element
function UIElements.GetByFrame(frame)
return private.activeFrameElementMap[frame]
end
--- Creates a WoW UI object and tracks it for easier debugging.
-- @tparam Element element The TSM UI element object
-- @tparam string frameType The type of the WoW UI object
-- @tparam ?string frameName The global name
-- @tparam ?table parentFrame The parent WoW UI object
-- @tparam ?string inheritsFrame A WoW UI template to inherit from
-- @treturn table The WoW UI object
function UIElements.CreateFrame(element, frameType, frameName, parentFrame, inheritsFrame)
local isNamed = frameName and true or false
if not frameName then
-- generate a debug name to aid in later lookup
frameName = private.GetDebugName(element)
end
private.debugNameElementLookup[frameName] = element
local frame = CreateFrame(frameType, frameName, parentFrame, inheritsFrame)
if not isNamed then
_G[frameName] = nil
end
return frame
end
--- Creates a WoW UI font string and tracks it for easier debugging.
-- @tparam Element element The TSM UI element object
-- @tparam table parentFrame The parent WoW UI frame
-- @treturn table The WoW UI font string
function UIElements.CreateFontString(element, parentFrame)
local name = private.GetDebugName(element)
private.debugNameElementLookup[name] = element
local fontString = parentFrame:CreateFontString(name)
_G[name] = nil
return fontString
end
--- Gets the debug name translations.
-- @tparam table result The result table
function UIElements.GetDebugNameTranslation(result)
for name, element in pairs(private.debugNameElementLookup) do
result[name] = "<"..tostring(element)..">"
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.NewElementHelper(elementType, id, name, ...)
local class = private.elementClasses[elementType]
local element = nil
if name then
private.namedElements[name] = private.namedElements[name] or class(name)
element = private.namedElements[name]
assert(_G[name] == element:_GetBaseFrame())
else
if not private.objectPools[class] then
private.objectPools[class] = ObjectPool.New("UI_"..class.__name, class, 1)
end
element = private.objectPools[class]:Get()
end
private.activeFrameElementMap[element:_GetBaseFrame()] = element
element:SetId(id)
element:SetTags(...)
element:Acquire()
return element
end
function private.GetDebugName(element)
return "TSM_UI_ELEMENT:"..element.__class.__name..":"..format("%06x", random(0, 2 ^ 24 - 1))
end

90
LibTSM/Util/Analytics.lua Normal file
View File

@@ -0,0 +1,90 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Analytics = TSM.Init("Util.Analytics")
local Debug = TSM.Include("Util.Debug")
local Log = TSM.Include("Util.Log")
local private = {
events = {},
lastEventTime = nil,
argsTemp = {},
session = time(),
sequenceNumber = 1,
}
local MAX_ANALYTICS_AGE = 14 * 24 * 60 * 60 -- 2 weeks
local HIT_TYPE_IS_VALID = {
AC = true,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function Analytics.Action(name, ...)
private.InsertHit("AC", name, ...)
end
function Analytics.Save(appDB)
appDB.analytics = appDB.analytics or {updateTime=0, data={}}
if private.lastEventTime then
appDB.analytics.updateTime = private.lastEventTime
end
-- remove any events which are too old
for i = #appDB.analytics.data, 1, -1 do
local _, _, timeStr = strsplit(",", appDB.analytics.data[i])
local eventTime = (tonumber(timeStr) or 0) / 1000
if eventTime < time() - MAX_ANALYTICS_AGE then
tremove(appDB.analytics.data, i)
end
end
for _, event in ipairs(private.events) do
tinsert(appDB.analytics.data, event)
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.InsertHit(hitType, ...)
assert(HIT_TYPE_IS_VALID[hitType])
wipe(private.argsTemp)
for i = 1, select("#", ...) do
local arg = select(i, ...)
local argType = type(arg)
if argType == "string" then
-- remove non-printable and non-ascii characters
arg = gsub(arg, "[^ -~]", "")
-- remove characters we don't want in the JSON
arg = gsub(arg, "[\\\"]", "")
arg = private.AddQuotes(arg)
elseif argType == "number" then
-- pass
elseif argType == "boolean" then
arg = tostring(arg)
else
error("Invalid arg type: "..argType)
end
tinsert(private.argsTemp, arg)
end
Log.Info("%s %s", hitType, strjoin(" ", tostringall(...)))
hitType = private.AddQuotes(hitType)
local version = private.AddQuotes(TSM.GetVersion() or "???")
local timeMs = Debug.GetTimeMilliseconds()
local jsonStr = strjoin(",", hitType, version, timeMs, private.session, private.sequenceNumber, unpack(private.argsTemp))
tinsert(private.events, "["..jsonStr.."]")
private.sequenceNumber = private.sequenceNumber + 1
private.lastEventTime = time()
end
function private.AddQuotes(str)
return "\""..str.."\""
end

99
LibTSM/Util/CSV.lua Normal file
View File

@@ -0,0 +1,99 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- CSV Functions
-- @module CSV
local _, TSM = ...
local CSV = TSM.Init("Util.CSV")
local TempTable = TSM.Include("Util.TempTable")
local private = {}
-- ============================================================================
-- Module Functions
-- ============================================================================
function CSV.EncodeStart(keys)
local context = TempTable.Acquire()
context.keys = keys
context.lines = TempTable.Acquire()
context.lineParts = TempTable.Acquire()
tinsert(context.lines, table.concat(keys, ","))
return context
end
function CSV.EncodeAddRowData(context, data)
wipe(context.lineParts)
for _, key in ipairs(context.keys) do
tinsert(context.lineParts, data[key] or "")
end
tinsert(context.lines, table.concat(context.lineParts, ","))
end
function CSV.EncodeAddRowDataRaw(context, ...)
tinsert(context.lines, strjoin(",", ...))
end
function CSV.EncodeEnd(context)
local result = table.concat(context.lines, "\n")
TempTable.Release(context.lineParts)
TempTable.Release(context.lines)
TempTable.Release(context)
return result
end
function CSV.Encode(keys, data)
local context = CSV.EncodeStart(keys)
for _, row in ipairs(data) do
CSV.EncodeAddRowData(context, row)
end
return CSV.EncodeEnd(context)
end
function CSV.DecodeStart(str, fields)
local func = gmatch(str, strrep("([^\n,]+),", #fields - 1).."([^\n,]+)(,?[^\n,]*)")
if strjoin(",", func()) ~= table.concat(fields, ",").."," then
return
end
local context = TempTable.Acquire()
context.func = func
context.extraArgPos = #fields + 1
context.result = true
return context
end
function CSV.DecodeIterator(context)
return private.DecodeIteratorHelper, context
end
function CSV.DecodeEnd(context)
local result = context.result
TempTable.Release(context)
return result
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.DecodeIteratorHelper(context)
return private.DecodeIteratorHelper2(context, context.func())
end
function private.DecodeIteratorHelper2(context, v1, ...)
if not v1 then
return
end
if select(context.extraArgPos, v1, ...) ~= "" then
context.result = false
return
end
return v1, ...
end

209
LibTSM/Util/Color.lua Normal file
View File

@@ -0,0 +1,209 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Color Functions.
-- @module Color
local _, TSM = ...
local Color = TSM.Init("Util.Color")
local Math = TSM.Include("Util.Math")
local HSLuv = TSM.Include("Util.HSLuv")
local private = {
context = {},
transparent = nil,
fullWhite = nil,
fullBlack = nil,
}
local TINT_VALUES = {
SELECTED = 15,
HOVER = 12,
SELECTED_HOVER = 20,
DISABLED = -40,
}
local OPACITY_VALUES = {
HIGHLIGHT = 50,
}
-- ============================================================================
-- Metatable
-- ============================================================================
local COLOR_MT = {
__index = {
GetTint = function(self, tintPct)
local context = private.context[self]
assert(context.hex)
if type(tintPct) == "string" then
local sign, tintKey = strmatch(tintPct, "^([%+%-])([A-Z_]+)$")
assert(TINT_VALUES[tintKey])
tintPct = tonumber(TINT_VALUES[tintKey]) * (sign == "+" and 1 or -1)
end
assert(type(tintPct) == "number")
if tintPct == 0 then
return self
end
if not context.tints[tintPct] then
local l = context.l + tintPct
l = min(l, 100)
assert(private.IsValidValue(l, 100))
local r, g, b = HSLuv.ToRGB(context.h, context.s, l)
context.tints[tintPct] = private.NewColorHelper(r, g, b, context.a)
end
return context.tints[tintPct]
end,
GetOpacity = function(self, opacityPct)
local context = private.context[self]
assert(context.hex)
if type(opacityPct) == "string" then
assert(OPACITY_VALUES[opacityPct])
opacityPct = tonumber(OPACITY_VALUES[opacityPct])
end
assert(private.IsValidValue(opacityPct, 100))
if opacityPct == 100 then
return self
end
if not context.opacities[opacityPct] then
assert(context.a == 255)
local a = Math.Round(255 * opacityPct / 100)
assert(private.IsValidValue(a, 255))
context.opacities[opacityPct] = private.NewColorHelper(context.r, context.g, context.b, a)
end
return context.opacities[opacityPct]
end,
GetRGBA = function(self)
local context = private.context[self]
assert(context.hex)
return context.r, context.g, context.b, context.a
end,
GetFractionalRGBA = function(self)
local context = private.context[self]
assert(context.hex)
return context.r / 255, context.g / 255, context.b / 255, context.a / 255
end,
IsLight = function(self)
local context = private.context[self]
assert(context.hex)
return context.l >= 50
end,
GetHex = function(self)
local context = private.context[self]
assert(context.hex)
return context.hex
end,
ColorText = function(self, text)
return self:GetTextColorPrefix()..text.."|r"
end,
GetTextColorPrefix = function(self)
local context = private.context[self]
assert(context.hex)
return format("|c%02x%02x%02x%02x", context.a, context.r, context.g, context.b)
end,
Equals = function(self, other)
return self:GetHex() == other:GetHex()
end,
},
__newindex = function(self, key, value) error("Color cannot be modified") end,
__metatable = false,
__tostring = function(self)
local context = private.context[self]
return "Color:"..context.hex
end,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
Color:OnModuleLoad(function()
private.transparent = private.NewColorHelper(0, 0, 0, 0)
private.fullWhite = private.NewColorHelper(255, 255, 255, 255)
private.fullBlack = private.NewColorHelper(0, 0, 0, 255)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Create a new color object from a hex string.
-- @tparam string hex The hex string which represents the color (in either "#AARRGGBB" or "#RRGGBB" format)
-- @treturn Color The color object
function Color.NewFromHex(hex)
return private.NewColorHelper(private.HexToRGBA(hex))
end
--- Returns whether or not the argument is a color object.
-- @param arg The argument to check
-- @treturn boolean Whether or not the argument is a color object.
function Color.IsInstance(arg)
return type(arg) == "table" and private.context[arg] and true or false
end
--- Gets a predefined fully-transparent color.
-- @treturn Color The color object
function Color.GetTransparent()
return private.transparent
end
--- Gets a predefined fully-opaque white color.
-- @treturn Color The color object
function Color.GetFullWhite()
return private.fullWhite
end
--- Gets a predefined fully-opaque black color.
-- @treturn Color The color object
function Color.GetFullBlack()
return private.fullBlack
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.NewColorHelper(r, g, b, a)
assert(private.IsValidValue(r, 255) and private.IsValidValue(g, 255) and private.IsValidValue(b, 255) and private.IsValidValue(a, 255))
if a == 0 then
assert(r == 0 and g == 0 and b == 0, "Invalid color with alpha of 0")
end
local context = {
tints = {},
opacities = {},
r = r,
g = g,
b = b,
a = a,
h = nil,
s = nil,
l = nil,
hex = private.RGBAToHex(r, g, b, a),
}
context.h, context.s, context.l = HSLuv.FromRGB(r, g, b)
context.hex = private.RGBAToHex(r, g, b, a)
local color = setmetatable({}, COLOR_MT)
private.context[color] = context
return color
end
function private.IsValidValue(value, maxValue)
return type(value) == "number" and value >= 0 and value <= maxValue and value == floor(value)
end
function private.HexToRGBA(hex)
local a, r, g, b = strmatch(strlower(hex), "^#([0-9a-f]?[0-9a-f]?)([0-9a-f][0-9a-f])([0-9a-f][0-9a-f])([0-9a-f][0-9a-f])$")
return tonumber(r, 16), tonumber(g, 16), tonumber(b, 16), tonumber(a ~= "" and a or "ff", 16)
end
function private.RGBAToHex(r, g, b, a)
return format("#%02x%02x%02x%02x", Math.Round(a), Math.Round(r), Math.Round(g), Math.Round(b))
end

127
LibTSM/Util/Database.lua Normal file
View File

@@ -0,0 +1,127 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Database Functions.
-- @module Database
local _, TSM = ...
local Database = TSM.Init("Util.Database")
local Constants = TSM.Include("Util.DatabaseClasses.Constants")
local Schema = TSM.Include("Util.DatabaseClasses.Schema")
local Table = TSM.Include("Util.DatabaseClasses.DBTable")
local private = {
dbByNameLookup = {},
infoNameDB = nil,
infoFieldDB = nil,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
Database:OnModuleLoad(function()
-- create our info database tables - don't use :Commit() to create these since that'll insert into these tables
private.infoNameDB = Database.NewSchema("DEBUG_INFO_NAME")
:AddUniqueStringField("name")
:AddIndex("name")
:Commit()
private.infoFieldDB = Database.NewSchema("DEBUG_INFO_FIELD")
:AddStringField("dbName")
:AddStringField("field")
:AddStringField("type")
:AddStringField("attributes")
:AddNumberField("order")
:AddIndex("dbName")
:Commit()
Table.SetCreateCallback(private.OnTableCreate)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Database.NewSchema(name)
return Schema.Get(name)
end
function Database.OtherFieldQueryParam(otherFieldName)
return Constants.OTHER_FIELD_QUERY_PARAM, otherFieldName
end
function Database.BoundQueryParam()
return Constants.BOUND_QUERY_PARAM
end
-- ============================================================================
-- Debug Functions
-- ============================================================================
function Database.InfoNameIterator()
return private.infoNameDB:NewQuery()
:Select("name")
:OrderBy("name", true)
:IteratorAndRelease()
end
function Database.CreateInfoFieldQuery(dbName)
return private.infoFieldDB:NewQuery()
:Equal("dbName", dbName)
end
function Database.GetNumRows(dbName)
return private.dbByNameLookup[dbName]:GetNumRows()
end
function Database.GetNumActiveQueries(dbName)
return #private.dbByNameLookup[dbName]._queries
end
function Database.CreateDBQuery(dbName)
return private.dbByNameLookup[dbName]:NewQuery()
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.OnTableCreate(tbl, schema)
local name = schema:_GetName()
assert(not private.dbByNameLookup[name], "A database with this name already exists")
private.dbByNameLookup[name] = tbl
private.infoNameDB:NewRow()
:SetField("name", name)
:Create()
for index, fieldName, fieldType, isIndex, isUnique in schema:_FieldIterator() do
local fieldAttributes = (isIndex and isUnique and "index,unique") or (isIndex and "index") or (isUnique and "unique") or ""
private.infoFieldDB:NewRow()
:SetField("dbName", name)
:SetField("field", fieldName)
:SetField("type", fieldType)
:SetField("attributes", fieldAttributes)
:SetField("order", index)
:Create()
end
for fieldName in schema:_MultiFieldIndexIterator() do
private.infoFieldDB:NewRow()
:SetField("dbName", name)
:SetField("field", fieldName)
:SetField("type", "-")
:SetField("attributes", "multi-field index")
:SetField("order", -1)
:Create()
end
end

View File

@@ -0,0 +1,12 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Constants = TSM.Init("Util.DatabaseClasses.Constants")
Constants.DB_INDEX_FIELD_SEP = "~"
Constants.DB_INDEX_VALUE_SEP = "\001"
Constants.OTHER_FIELD_QUERY_PARAM = newproxy()
Constants.BOUND_QUERY_PARAM = newproxy()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,447 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local QueryClause = TSM.Init("Util.DatabaseClasses.QueryClause")
local Constants = TSM.Include("Util.DatabaseClasses.Constants")
local Util = TSM.Include("Util.DatabaseClasses.Util")
local ObjectPool = TSM.Include("Util.ObjectPool")
local LibTSMClass = TSM.Include("LibTSMClass")
local DatabaseQueryClause = LibTSMClass.DefineClass("DatabaseQueryClause")
local private = {
objectPool = nil,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
QueryClause:OnModuleLoad(function()
private.objectPool = ObjectPool.New("DATABASE_QUERY_CLAUSES", DatabaseQueryClause, 1)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function QueryClause.Get(query, parent)
local clause = private.objectPool:Get()
clause:_Acquire(query, parent)
return clause
end
-- ============================================================================
-- Class Method Methods
-- ============================================================================
function DatabaseQueryClause.__init(self)
self._query = nil
self._operation = nil
self._parent = nil
-- comparison
self._field = nil
self._value = nil
self._boundValue = nil
self._otherField = nil
-- or / and
self._subClauses = {}
end
function DatabaseQueryClause._Acquire(self, query, parent)
self._query = query
self._parent = parent
end
function DatabaseQueryClause._Release(self)
self._query = nil
self._operation = nil
self._parent = nil
self._field = nil
self._value = nil
self._boundValue = nil
self._otherField = nil
for _, clause in ipairs(self._subClauses) do
clause:_Release()
end
wipe(self._subClauses)
private.objectPool:Recycle(self)
end
-- ============================================================================
-- Public Class Method
-- ============================================================================
function DatabaseQueryClause.Equal(self, field, value, otherField)
return self:_SetComparisonOperation("EQUAL", field, value, otherField)
end
function DatabaseQueryClause.NotEqual(self, field, value, otherField)
return self:_SetComparisonOperation("NOT_EQUAL", field, value, otherField)
end
function DatabaseQueryClause.LessThan(self, field, value, otherField)
return self:_SetComparisonOperation("LESS", field, value, otherField)
end
function DatabaseQueryClause.LessThanOrEqual(self, field, value, otherField)
return self:_SetComparisonOperation("LESS_OR_EQUAL", field, value, otherField)
end
function DatabaseQueryClause.GreaterThan(self, field, value, otherField)
return self:_SetComparisonOperation("GREATER", field, value, otherField)
end
function DatabaseQueryClause.GreaterThanOrEqual(self, field, value, otherField)
return self:_SetComparisonOperation("GREATER_OR_EQUAL", field, value, otherField)
end
function DatabaseQueryClause.Matches(self, field, value)
return self:_SetComparisonOperation("MATCHES", field, value)
end
function DatabaseQueryClause.Contains(self, field, value)
return self:_SetComparisonOperation("CONTAINS", field, value)
end
function DatabaseQueryClause.StartsWith(self, field, value)
return self:_SetComparisonOperation("STARTS_WITH", field, value)
end
function DatabaseQueryClause.IsNil(self, field)
return self:_SetComparisonOperation("IS_NIL", field)
end
function DatabaseQueryClause.IsNotNil(self, field)
return self:_SetComparisonOperation("IS_NOT_NIL", field)
end
function DatabaseQueryClause.Custom(self, func, arg)
return self:_SetComparisonOperation("CUSTOM", func, arg)
end
function DatabaseQueryClause.HashEqual(self, fields, value)
return self:_SetComparisonOperation("HASH_EQUAL", fields, value)
end
function DatabaseQueryClause.InTable(self, field, value)
return self:_SetComparisonOperation("IN_TABLE", field, value)
end
function DatabaseQueryClause.NotInTable(self, field, value)
return self:_SetComparisonOperation("NOT_IN_TABLE", field, value)
end
function DatabaseQueryClause.Or(self)
return self:_SetSubClauseOperation("OR")
end
function DatabaseQueryClause.And(self)
return self:_SetSubClauseOperation("AND")
end
-- ============================================================================
-- Private Class Method
-- ============================================================================
function DatabaseQueryClause._GetParent(self)
return self._parent
end
function DatabaseQueryClause._IsTrue(self, row)
local value = self._value
if value == Constants.BOUND_QUERY_PARAM then
value = self._boundValue
elseif value == Constants.OTHER_FIELD_QUERY_PARAM then
value = row:GetField(self._otherField)
end
local operation = self._operation
if operation == "EQUAL" then
return row[self._field] == value
elseif operation == "NOT_EQUAL" then
return row[self._field] ~= value
elseif operation == "LESS" then
return row[self._field] < value
elseif operation == "LESS_OR_EQUAL" then
return row[self._field] <= value
elseif operation == "GREATER" then
return row[self._field] > value
elseif operation == "GREATER_OR_EQUAL" then
return row[self._field] >= value
elseif operation == "MATCHES" then
return strfind(strlower(row[self._field]), value) and true or false
elseif operation == "CONTAINS" then
return strfind(strlower(row[self._field]), value, 1, true) and true or false
elseif operation == "STARTS_WITH" then
return strsub(strlower(row[self._field]), 1, #value) == value
elseif operation == "IS_NIL" then
return row[self._field] == nil
elseif operation == "IS_NOT_NIL" then
return row[self._field] ~= nil
elseif operation == "CUSTOM" then
return self._field(row, value) and true or false
elseif operation == "HASH_EQUAL" then
return row:CalculateHash(self._field) == value
elseif operation == "IN_TABLE" then
return value[row[self._field]] ~= nil
elseif operation == "NOT_IN_TABLE" then
return value[row[self._field]] == nil
elseif operation == "OR" then
for i = 1, #self._subClauses do
if self._subClauses[i]:_IsTrue(row) then
return true
end
end
return false
elseif operation == "AND" then
for i = 1, #self._subClauses do
if not self._subClauses[i]:_IsTrue(row) then
return false
end
end
return true
else
error("Invalid operation: " .. tostring(operation))
end
end
function DatabaseQueryClause._GetIndexValue(self, indexField)
if self._operation == "EQUAL" then
if self._field ~= indexField then
return
end
if self._value == Constants.OTHER_FIELD_QUERY_PARAM then
return
elseif self._value == Constants.BOUND_QUERY_PARAM then
local result = Util.ToIndexValue(self._boundValue)
return result, result
else
local result = Util.ToIndexValue(self._value)
return result, result
end
elseif self._operation == "LESS_OR_EQUAL" then
if self._field ~= indexField then
return
end
if self._value == Constants.OTHER_FIELD_QUERY_PARAM then
return
elseif self._value == Constants.BOUND_QUERY_PARAM then
return nil, Util.ToIndexValue(self._boundValue)
else
return nil, Util.ToIndexValue(self._value)
end
elseif self._operation == "GREATER_OR_EQUAL" then
if self._field ~= indexField then
return
end
if self._value == Constants.OTHER_FIELD_QUERY_PARAM then
return
elseif self._value == Constants.BOUND_QUERY_PARAM then
return Util.ToIndexValue(self._boundValue), nil
else
return Util.ToIndexValue(self._value), nil
end
elseif self._operation == "STARTS_WITH" then
if self._field ~= indexField then
return
end
local minValue = nil
if self._value == Constants.OTHER_FIELD_QUERY_PARAM then
return
elseif self._value == Constants.BOUND_QUERY_PARAM then
minValue = Util.ToIndexValue(self._boundValue)
else
minValue = Util.ToIndexValue(self._value)
end
-- calculate the max value
assert(gsub(minValue, "\255", "") ~= "")
local maxValue = nil
for i = #minValue, 1, -1 do
if strsub(minValue, i, i) ~= "\255" then
maxValue = strsub(minValue, 1, i - 1)..strrep("\255", #minValue - i + 1)
break
end
end
return minValue, maxValue
elseif self._operation == "OR" then
local numSubClauses = #self._subClauses
if numSubClauses == 0 then
return
end
-- all of the subclauses need to support the same index
local valueMin, valueMax = self._subClauses[1]:_GetIndexValue(indexField)
for i = 2, numSubClauses do
local subClauseValueMin, subClauseValueMax = self._subClauses[i]:_GetIndexValue(indexField)
if subClauseValueMin ~= valueMin or subClauseValueMax ~= valueMax then
return
end
end
return valueMin, valueMax
elseif self._operation == "AND" then
-- get the most constrained range of index values from the subclauses
local valueMin, valueMax = nil, nil
for _, subClause in ipairs(self._subClauses) do
local subClauseValueMin, subClauseValueMax = subClause:_GetIndexValue(indexField)
if subClauseValueMin ~= nil and (valueMin == nil or subClauseValueMin > valueMin) then
valueMin = subClauseValueMin
end
if subClauseValueMax ~= nil and (valueMax == nil or subClauseValueMax < valueMax) then
valueMax = subClauseValueMax
end
end
return valueMin, valueMax
end
end
function DatabaseQueryClause._GetTrigramIndexValue(self, indexField)
if self._operation == "EQUAL" then
if self._field ~= indexField then
return
end
if self._value == Constants.OTHER_FIELD_QUERY_PARAM then
return
elseif self._value == Constants.BOUND_QUERY_PARAM then
return self._boundValue
else
return self._value
end
elseif self._operation == "CONTAINS" then
if self._field ~= indexField then
return
end
if self._value == Constants.OTHER_FIELD_QUERY_PARAM then
return
elseif self._value == Constants.BOUND_QUERY_PARAM then
return self._boundValue
else
return self._value
end
elseif self._operation == "OR" then
-- all of the subclauses need to support the same trigram value
local value = nil
for i = 1, #self._subClauses do
local subClause = self._subClauses[i]
local subClauseValue = subClause:_GetTrigramIndexValue(indexField)
if not subClauseValue then
return
end
if i == 1 then
value = subClauseValue
elseif subClauseValue ~= value then
return
end
end
return value
elseif self._operation == "AND" then
-- at least one of the subclauses need to support the trigram
for _, subClause in ipairs(self._subClauses) do
local value = subClause:_GetTrigramIndexValue(indexField)
if value then
return value
end
end
end
end
function DatabaseQueryClause._IsStrictIndex(self, indexField, indexValueMin, indexValueMax)
if self._value == Constants.OTHER_FIELD_QUERY_PARAM then
return false
end
if self._operation == "EQUAL" and self._field == indexField and indexValueMin == indexValueMax then
if self._value == Constants.BOUND_QUERY_PARAM then
return Util.ToIndexValue(self._boundValue) == indexValueMin
else
return Util.ToIndexValue(self._value) == indexValueMin
end
elseif self._operation == "GREATER_OR_EQUAL" and self._field == indexField then
if self._value == Constants.BOUND_QUERY_PARAM then
return Util.ToIndexValue(self._boundValue) == indexValueMin
else
return Util.ToIndexValue(self._value) == indexValueMin
end
elseif self._operation == "LESS_OR_EQUAL" and self._field == indexField then
if self._value == Constants.BOUND_QUERY_PARAM then
return Util.ToIndexValue(self._boundValue) == indexValueMax
else
return Util.ToIndexValue(self._value) == indexValueMax
end
elseif self._operation == "OR" and #self._subClauses == 1 then
return self._subClauses[1]:_IsStrictIndex(indexField, indexValueMin, indexValueMax)
elseif self._operation == "AND" then
-- must be strict for all subclauses
for _, subClause in ipairs(self._subClauses) do
if not subClause:_IsStrictIndex(indexField, indexValueMin, indexValueMax) then
return false
end
end
return true
else
return false
end
end
function DatabaseQueryClause._UsesField(self, field)
if field == self._field or self._operation == "CUSTOM" then
return true
end
if self._operation == "OR" or self._operation == "AND" then
for i = 1, #self._subClauses do
if self._subClauses[i]:_UsesField(field) then
return true
end
end
end
return false
end
function DatabaseQueryClause._InsertSubClause(self, subClause)
assert(self._operation == "OR" or self._operation == "AND")
tinsert(self._subClauses, subClause)
self._query:_MarkResultStale()
return self
end
function DatabaseQueryClause._SetComparisonOperation(self, operation, field, value, otherField)
assert(not self._operation)
assert(value == Constants.OTHER_FIELD_QUERY_PARAM or not otherField)
self._operation = operation
self._field = field
self._value = value
self._otherField = otherField
self._query:_MarkResultStale()
return self
end
function DatabaseQueryClause._SetSubClauseOperation(self, operation)
assert(not self._operation)
self._operation = operation
assert(#self._subClauses == 0)
self._query:_MarkResultStale()
return self
end
function DatabaseQueryClause._BindParams(self, ...)
if self._value == Constants.BOUND_QUERY_PARAM then
self._boundValue = ...
self._query:_MarkResultStale()
return 1
end
local valuesUsed = 0
for _, clause in ipairs(self._subClauses) do
valuesUsed = valuesUsed + clause:_BindParams(select(valuesUsed + 1, ...))
end
self._query:_MarkResultStale()
return valuesUsed
end

View File

@@ -0,0 +1,297 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local QueryResultRow = TSM.Init("Util.DatabaseClasses.QueryResultRow")
local Math = TSM.Include("Util.Math")
local TempTable = TSM.Include("Util.TempTable")
local ObjectPool = TSM.Include("Util.ObjectPool")
local private = {
context = {},
objectPool = nil,
}
-- ============================================================================
-- Metatable
-- ============================================================================
local ROW_PROTOTYPE = {
_Acquire = function(self, db, query, newRowUUID)
local context = private.context[self]
context.db = db
context.query = query
context.isNewRow = newRowUUID and true or false
if newRowUUID then
context.uuid = newRowUUID
end
end,
_Release = function(self)
local context = private.context[self]
context.db = nil
context.query = nil
context.isNewRow = nil
context.uuid = nil
assert(not context.pendingChanges)
wipe(self)
end,
Release = function(self)
self:_Release()
private.objectPool:Recycle(self)
end,
_SetUUID = function(self, uuid)
local context = private.context[self]
context.uuid = uuid
wipe(self)
end,
GetUUID = function(self)
local uuid = private.context[self].uuid
assert(uuid)
return uuid
end,
GetQuery = function(self)
local query = private.context[self].query
assert(query)
return query
end,
GetField = function(self, field, ...)
if ... then
error("GetField() only supports 1 field")
end
return self[field]
end,
GetFields = function(self, ...)
local numFields = select("#", ...)
local field1, field2, field3, field4, field5, field6, field7, field8, field9, field10 = ...
if numFields == 0 then
return
elseif numFields == 1 then
return self[field1]
elseif numFields == 2 then
return self[field1], self[field2]
elseif numFields == 3 then
return self[field1], self[field2], self[field3]
elseif numFields == 4 then
return self[field1], self[field2], self[field3], self[field4]
elseif numFields == 5 then
return self[field1], self[field2], self[field3], self[field4], self[field5]
elseif numFields == 6 then
return self[field1], self[field2], self[field3], self[field4], self[field5], self[field6]
elseif numFields == 7 then
return self[field1], self[field2], self[field3], self[field4], self[field5], self[field6], self[field7]
elseif numFields == 8 then
return self[field1], self[field2], self[field3], self[field4], self[field5], self[field6], self[field7], self[field8]
elseif numFields == 9 then
return self[field1], self[field2], self[field3], self[field4], self[field5], self[field6], self[field7], self[field8], self[field9]
elseif numFields == 10 then
return self[field1], self[field2], self[field3], self[field4], self[field5], self[field6], self[field7], self[field8], self[field9], self[field10]
else
error("GetFields() only supports up to 10 fields")
end
end,
CalculateHash = function(self, fields)
local hash = nil
for _, field in ipairs(fields) do
hash = Math.CalculateHash(self[field], hash)
end
return hash
end,
SetField = function(self, field, value)
local context = private.context[self]
local isSameValue = not context.isNewRow and value == self[field]
if isSameValue and not context.pendingChanges then
-- setting to the same value, so ignore this call
return self
end
if context.db:_IsSmartMapField(field) then
error(format("Cannot set smart map field (%s)", tostring(field)), 3)
end
local fieldType = context.db:_GetFieldType(field)
if not fieldType then
error(format("Field %s doesn't exist", tostring(field)), 3)
elseif fieldType ~= type(value) then
error(format("Field %s should be a %s, got %s", tostring(field), tostring(fieldType), type(value)), 2)
end
if isSameValue then
-- setting the field to its original value, so clear any pending change
context.pendingChanges[field] = nil
if not next(context.pendingChanges) then
TempTable.Release(context.pendingChanges)
context.pendingChanges = nil
end
else
context.pendingChanges = context.pendingChanges or TempTable.Acquire()
context.pendingChanges[field] = value
end
return self
end,
_CreateHelper = function(self)
local context = private.context[self]
assert(context.isNewRow and context.pendingChanges)
-- make sure all the fields are set
for field in context.db:FieldIterator() do
assert(context.pendingChanges[field] ~= nil)
end
-- apply all the pending changes
for field, value in pairs(context.pendingChanges) do
-- cache this new value
rawset(self, field, value)
end
TempTable.Release(context.pendingChanges)
context.pendingChanges = nil
context.isNewRow = nil
end,
Create = function(self)
self:_CreateHelper()
private.context[self].db:_InsertRow(self)
end,
CreateAndClone = function(self)
self:_CreateHelper()
local clonedRow = self:Clone()
private.context[self].db:_InsertRow(self)
return clonedRow
end,
Update = function(self)
local context = private.context[self]
assert(not context.isNewRow)
if not context.pendingChanges then
return
end
-- apply all the pending changes
local oldValues = TempTable.Acquire()
for field, value in pairs(context.pendingChanges) do
oldValues[field] = self[field]
-- cache this new value
rawset(self, field, value)
end
TempTable.Release(context.pendingChanges)
context.pendingChanges = nil
context.db:_UpdateRow(self, oldValues)
TempTable.Release(oldValues)
return self
end,
CreateOrUpdateAndRelease = function(self)
local context = private.context[self]
if context.isNewRow then
self:Create()
else
self:Update()
self:Release()
end
end,
Clone = function(self)
local context = private.context[self]
assert(not context.isNewRow and not context.pendingChanges)
local newRow = QueryResultRow.Get()
newRow:_Acquire(context.db)
newRow:_SetUUID(context.uuid)
return newRow
end,
}
local ROW_MT = {
-- getter
__index = function(self, key)
if key == nil then
error("Attempt to get nil key")
end
if ROW_PROTOTYPE[key] then
return ROW_PROTOTYPE[key]
end
-- cache the value
local context = private.context[self]
if context.isNewRow then
error("Getting value on a new row: "..tostring(key))
end
local result = nil
if context.query then
-- use the query to lookup the result
result = context.query:_GetResultRowData(context.uuid, key)
else
-- we're not tied to a query so this should be a local DB field
if not context.db:_GetFieldType(key) then
error("Invalid field: "..tostring(key), 2)
end
result = context.db:GetRowFieldByUUID(context.uuid, key)
end
if result ~= nil then
rawset(self, key, result)
end
return result
end,
-- setter
__newindex = function(self, key, value)
error("Table is read-only", 2)
end,
__eq = function(self, other)
local uuid = private.context[self].uuid
local uuidOther = private.context[other].uuid
return uuid and uuidOther and uuid == uuidOther
end,
__tostring = function(self)
local context = private.context[self]
return "QueryResultRow:"..strmatch(tostring(context), "table:[^0-9a-fA-F]*([0-9a-fA-F]+)")..":"..self:GetUUID()
end,
__metatable = false,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
QueryResultRow:OnModuleLoad(function()
private.objectPool = ObjectPool.New("DATABASE_QUERY_RESULT_ROWS", private.CreateNew, 2)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function QueryResultRow.Get()
return private.objectPool:Get()
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.CreateNew()
local row = setmetatable({}, ROW_MT)
private.context[row] = {
db = nil,
query = nil,
isNewRow = nil,
uuid = nil,
}
return row
end

View File

@@ -0,0 +1,198 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Schema = TSM.Init("Util.DatabaseClasses.Schema")
local Constants = TSM.Include("Util.DatabaseClasses.Constants")
local DBTable = TSM.Include("Util.DatabaseClasses.DBTable")
local ObjectPool = TSM.Include("Util.ObjectPool")
local LibTSMClass = TSM.Include("LibTSMClass")
local DatabaseSchema = LibTSMClass.DefineClass("DatabaseSchema")
local private = {
objectPool = nil,
}
local FIELD_TYPE_IS_VALID = {
string = true,
number = true,
boolean = true,
}
local MAX_MULTI_FIELD_INDEX_PARTS = 2
-- ============================================================================
-- Modules Functions
-- ============================================================================
function Schema.Get(name)
if not private.objectPool then
private.objectPool = ObjectPool.New("DATABASE_SCHEMAS", DatabaseSchema, 2)
end
local schema = private.objectPool:Get()
schema:_Acquire(name)
return schema
end
function Schema.IsClass(obj)
return obj:__isa(DatabaseSchema)
end
-- ============================================================================
-- Class Method Methods
-- ============================================================================
function DatabaseSchema.__init(self)
self._name = nil
self._fieldList = {}
self._fieldTypeLookup = {}
self._isIndex = {}
self._isUnique = {}
self._smartMapLookup = {}
self._smartMapInputLookup = {}
self._trigramIndexField = nil
end
function DatabaseSchema._Acquire(self, name)
assert(type(name) == "string")
self._name = name
end
function DatabaseSchema._Release(self)
self._name = nil
wipe(self._fieldList)
wipe(self._fieldTypeLookup)
wipe(self._isIndex)
wipe(self._isUnique)
wipe(self._smartMapLookup)
wipe(self._smartMapInputLookup)
self._trigramIndexField = nil
end
-- ============================================================================
-- Public Class Method
-- ============================================================================
function DatabaseSchema.Release(self)
self:_Release()
private.objectPool:Recycle(self)
end
function DatabaseSchema.AddStringField(self, fieldName)
self:_AddField("string", fieldName)
return self
end
function DatabaseSchema.AddNumberField(self, fieldName)
self:_AddField("number", fieldName)
return self
end
function DatabaseSchema.AddBooleanField(self, fieldName)
self:_AddField("boolean", fieldName)
return self
end
function DatabaseSchema.AddUniqueStringField(self, fieldName)
self:_AddField("string", fieldName, true)
self._isUnique[fieldName] = true
return self
end
function DatabaseSchema.AddUniqueNumberField(self, fieldName)
self:_AddField("number", fieldName, true)
return self
end
function DatabaseSchema.AddSmartMapField(self, fieldName, map, inputFieldName)
assert(self._fieldTypeLookup[inputFieldName] == map:GetKeyType())
self:_AddField(map:GetValueType(), fieldName)
self._smartMapLookup[fieldName] = map
self._smartMapInputLookup[fieldName] = inputFieldName
return self
end
function DatabaseSchema.AddIndex(self, ...)
local numFields = select("#", ...)
assert(numFields > 0)
assert(numFields <= MAX_MULTI_FIELD_INDEX_PARTS, "Unsupported number of fields in index")
for i = 1, numFields do
local fieldName = select(i, ...)
assert(self._fieldTypeLookup[fieldName])
end
self._isIndex[strjoin(Constants.DB_INDEX_FIELD_SEP, ...)] = true
return self
end
function DatabaseSchema.AddTrigramIndex(self, fieldName)
assert(not self._trigramIndexField)
self._trigramIndexField = fieldName
return self
end
function DatabaseSchema.Commit(self)
local db = DBTable.Create(self)
self:Release()
return db
end
-- ============================================================================
-- Private Class Method
-- ============================================================================
function DatabaseSchema._GetName(self)
return self._name
end
function DatabaseSchema._AddField(self, fieldType, fieldName, isUnique)
assert(FIELD_TYPE_IS_VALID[fieldType])
assert(type(fieldName) == "string" and strsub(fieldName, 1, 1) ~= "_" and not strmatch(fieldName, Constants.DB_INDEX_FIELD_SEP))
assert(not self._fieldTypeLookup[fieldName])
tinsert(self._fieldList, fieldName)
self._fieldTypeLookup[fieldName] = fieldType
if isUnique then
self._isUnique[fieldName] = true
end
end
function DatabaseSchema._FieldIterator(self)
return private.FieldIterator, self, 0
end
function DatabaseSchema._MultiFieldIndexIterator(self)
return private.MultiFieldIndexIterator, self, nil
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.FieldIterator(self, index)
index = index + 1
if index > #self._fieldList then
return
end
local fieldName = self._fieldList[index]
return index, fieldName, self._fieldTypeLookup[fieldName], self._isIndex[fieldName], self._isUnique[fieldName], self._smartMapLookup[fieldName], self._smartMapInputLookup[fieldName]
end
function private.MultiFieldIndexIterator(self, fieldName)
while true do
fieldName = next(self._isIndex, fieldName)
if not fieldName then
return
end
if strmatch(fieldName, Constants.DB_INDEX_FIELD_SEP) then
return fieldName
end
end
end

View File

@@ -0,0 +1,31 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Util = TSM.Init("Util.DatabaseClasses.Util")
local Math = TSM.Include("Util.Math")
-- ============================================================================
-- Module Functions
-- ============================================================================
function Util.ToIndexValue(value)
if value == nil then
return nil
end
local valueType = type(value)
if valueType == "string" then
return strlower(value)
elseif valueType == "boolean" then
return value and 1 or 0
elseif valueType == "number" and Math.IsNan(value) then
return nil
else
return value
end
end

85
LibTSM/Util/Debug.lua Normal file
View File

@@ -0,0 +1,85 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Debug Functions
-- @module Debug
local _, TSM = ...
local Debug = TSM.Init("Util.Debug")
local private = {
startSystemTimeMs = floor(GetTime() * 1000),
startTimeMs = time() * 1000 + (floor(GetTime() * 1000) % 1000),
}
local ADDON_NAME_SHORTEN_PATTERN = {
-- shorten "TradeSkillMaster" to "TSM"
[".-lMaster\\"] = "TSM\\",
[".-r\\LibTSM"] = "TSM\\LibTSM",
}
local IGNORED_STACK_LEVEL_MATCHERS = {
-- ignore wrapper code from LibTSMClass
"LibTSMClass%.lua:",
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Gets the current time in milliseconds since epoch
-- The time returned could be up to a second off absolutely, but relative times are guarenteed to be accurate.
-- @treturn number The current time in milliseconds since epoch
function Debug.GetTimeMilliseconds()
local systemTimeMs = floor(GetTime() * 1000)
return private.startTimeMs + (systemTimeMs - private.startSystemTimeMs)
end
--- Gets the location string for the specified stack level
-- @tparam number targetLevel The stack level to get the location for
-- @tparam[opt] thread thread The thread to get the location for
-- @treturn string The location string
function Debug.GetStackLevelLocation(targetLevel, thread)
targetLevel = targetLevel + 1
assert(targetLevel > 0)
local level = 1
while true do
local stackLine = nil
if thread then
stackLine = debugstack(thread, level, 1, 0)
else
stackLine = debugstack(level, 1, 0)
end
if not stackLine or stackLine == "" then
return
end
if TSM.IsWowClassic() or TSM.__IS_TEST_ENV then
stackLine = strmatch(stackLine, "^%.*([^:]+:%d+):")
else
local numSubs = nil
stackLine, numSubs = gsub(stackLine, "^%[string \"@([^%.]+%.lua)\"%](:%d+).*$", "%1%2")
stackLine = numSubs > 0 and stackLine or nil
end
if stackLine then
local ignored = false
for _, matchStr in ipairs(IGNORED_STACK_LEVEL_MATCHERS) do
if strmatch(stackLine, matchStr) then
ignored = true
break
end
end
if not ignored then
targetLevel = targetLevel - 1
if targetLevel == 0 then
stackLine = gsub(stackLine, "/", "\\")
for matchStr, replaceStr in pairs(ADDON_NAME_SHORTEN_PATTERN) do
stackLine = gsub(stackLine, matchStr, replaceStr)
end
return stackLine
end
end
end
level = level + 1
end
end

177
LibTSM/Util/Delay.lua Normal file
View File

@@ -0,0 +1,177 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Delay Functions
-- @module Delay
local _, TSM = ...
local Delay = TSM.Init("Util.Delay")
local Debug = TSM.Include("Util.Debug")
local Log = TSM.Include("Util.Log")
local TempTable = TSM.Include("Util.TempTable")
local private = {
delays = {},
frameNumber = 0,
frame = nil,
}
local CALLBACK_TIME_WARNING_THRESHOLD_MS = 20
local MIN_TIME_DURATION = 0.0001
-- ============================================================================
-- Module Loading
-- ============================================================================
Delay:OnModuleLoad(function()
private.frame = CreateFrame("Frame")
private.frame:SetScript("OnUpdate", private.ProcessDelays)
private.frame:Show()
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Call a callback after a set amount of time.
-- Note that the delay may be up to 1 frame time longer than requested.
-- @tparam[opt] string label A label for the delay (to allow it to be cancelled)
-- @tparam number duration The amount of time to delay for
-- @tparam function callback The function called when the delay is finished
-- @tparam[opt] number repeatDelay The amount of time to set this delay for once it completes
-- @param[opt=nil] context A context value to pass along to the callback (ignored if the delay was previously started)
function Delay.AfterTime(label, duration, callback, repeatDelay, context)
if type(label) == "number" then
-- no label specified
assert(not repeatDelay)
duration, callback, repeatDelay, context = label, duration, callback, repeatDelay
label = nil
end
assert(type(duration) == "number" and type(callback) == "function" and (not repeatDelay or type(repeatDelay) == "number"))
repeatDelay = repeatDelay and max(repeatDelay, MIN_TIME_DURATION) or nil
duration = max(duration, MIN_TIME_DURATION)
if label then
for _, delay in ipairs(private.delays) do
if delay.label == label then
-- delay is already running, so just return
return
end
end
else
label = Debug.GetStackLevelLocation(2)
end
local delayTbl = TempTable.Acquire()
delayTbl.endTime = GetTime() + duration
delayTbl.callback = callback
delayTbl.label = label
delayTbl.repeatDelay = repeatDelay
delayTbl.context = context
tinsert(private.delays, delayTbl)
end
--- Call a callback after a set number of frames.
-- Note that the delay may be up to 1 frame time longer than requested.
-- @tparam[opt] string label A label for the delay (to allow it to be cancelled)
-- @tparam number duration The number of frames to delay for
-- @tparam function callback The function called when the delay is finished
-- @tparam[opt] number repeatDelay The number of frames to set this delay for once it completes
-- @param[opt=nil] context A context value to pass along to the callback (ignored if the delay was previously started)
function Delay.AfterFrame(label, duration, callback, repeatDelay, context)
if type(label) == "number" then
-- no label specified
assert(not repeatDelay)
duration, callback, repeatDelay, context = label, duration, callback, repeatDelay
label = nil
end
assert(type(duration) == "number" and type(callback) == "function" and (not repeatDelay or type(repeatDelay) == "number"))
repeatDelay = repeatDelay and max(repeatDelay, 1) or nil
duration = max(duration, 1)
if label then
for _, delay in ipairs(private.delays) do
if delay.label == label then
-- delay is already running, so just return
return
end
end
else
label = Debug.GetStackLevelLocation(2)
assert(label)
end
local delayTbl = TempTable.Acquire()
delayTbl.endFrame = private.frameNumber + duration
delayTbl.callback = callback
delayTbl.label = label
delayTbl.repeatDelay = repeatDelay
delayTbl.context = context
tinsert(private.delays, delayTbl)
end
--- Cancel a delay.
-- This works for both time and frame delays.
-- @tparam string label The label the delay was created with
function Delay.Cancel(label)
for i, delay in ipairs(private.delays) do
if delay.label == label then
TempTable.Release(tremove(private.delays, i))
return
end
end
end
-- ============================================================================
-- Main Delay Callback
-- ============================================================================
function private.ProcessDelays()
private.frameNumber = private.frameNumber + 1
-- the delays can change as we do our callbacks, so keep looping through them until there are no more pending
while true do
local pendingLabel, pendingCallback, pendingContext = nil, nil, nil
for i, delay in ipairs(private.delays) do
assert(delay.endFrame or delay.endTime)
if delay.endFrame and delay.endFrame <= private.frameNumber then
pendingLabel = delay.label
pendingCallback = delay.callback
pendingContext = delay.context
if delay.repeatDelay then
delay.endFrame = private.frameNumber + delay.repeatDelay
else
TempTable.Release(tremove(private.delays, i))
end
break
elseif delay.endTime and delay.endTime <= GetTime() then
pendingLabel = delay.label
pendingCallback = delay.callback
pendingContext = delay.context
if delay.repeatDelay then
delay.endTime = GetTime() + delay.repeatDelay
else
TempTable.Release(tremove(private.delays, i))
end
break
end
end
if not pendingLabel then
-- no more pending delays to process
assert(not pendingCallback)
break
end
local startTime = debugprofilestop()
pendingCallback(pendingContext)
local timeTaken = debugprofilestop() - startTime
if timeTaken > CALLBACK_TIME_WARNING_THRESHOLD_MS then
Log.Warn("Delay callback (%s) took %0.2fms", pendingLabel, timeTaken)
end
end
end

112
LibTSM/Util/Event.lua Normal file
View File

@@ -0,0 +1,112 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Event Functions
-- @module Event
local _, TSM = ...
local Event = TSM.Init("Util.Event")
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local private = {
registry = {
event = {},
callback = {},
},
eventFrame = nil,
temp = {},
eventQueue = {},
processingEvent = false,
}
local CALLBACK_TIME_WARNING_THRESHOLD_MS = 20
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Registers an event callback.
-- @tparam string event The WoW event to register for (i.e. BAG_UPDATE)
-- @tparam function callback The function to be called from the event handler
function Event.Register(event, callback)
assert(type(event) == "string" and event == strupper(event) and type(callback) == "function")
-- make sure this event/callback isn't already registered
for i = 1, #private.registry.event do
assert(private.registry.event[i] ~= event or private.registry.callback[i] ~= callback)
end
private.eventFrame:RegisterEvent(event)
tinsert(private.registry.event, event)
tinsert(private.registry.callback, callback)
end
--- Unregisters an event callback.
-- @tparam string event The WoW event which the callback was registered for
-- @tparam function callback The function which was passed to @{Event.Register} for this event
function Event.Unregister(event, callback)
assert(type(event) == "string" and event == strupper(event) and type(callback) == "function")
local index = nil
local shouldUnregister = true
for i = 1, #private.registry.event do
if private.registry.event[i] == event and private.registry.callback[i] == callback then
assert(not index)
index = i
elseif private.registry.event[i] == event then
shouldUnregister = false
end
end
assert(index)
tremove(private.registry.event, index)
tremove(private.registry.callback, index)
if shouldUnregister then
private.eventFrame:UnregisterEvent(event)
end
end
-- ============================================================================
-- Event Frame
-- ============================================================================
function private.ProcessEvent(event, ...)
-- NOTE: the registered events may change within the callback, so copy them to a temp table
wipe(private.temp)
for i = 1, #private.registry.event do
if private.registry.event[i] == event then
tinsert(private.temp, private.registry.callback[i])
end
end
for _, callback in ipairs(private.temp) do
local startTime = debugprofilestop()
callback(event, ...)
local timeTaken = debugprofilestop() - startTime
if timeTaken > CALLBACK_TIME_WARNING_THRESHOLD_MS then
Log.Warn("Event (%s) callback took %.2fms", event, timeTaken)
end
end
end
function private.EventHandler(_, event, ...)
if private.processingEvent then
-- we are already in the middle of processing another event, so queue this one up
tinsert(private.eventQueue, TempTable.Acquire(event, ...))
assert(#private.eventQueue < 50)
return
end
private.processingEvent = true
private.ProcessEvent(event, ...)
-- process queued events
while #private.eventQueue > 0 do
local tbl = tremove(private.eventQueue, 1)
private.ProcessEvent(TempTable.UnpackAndRelease(tbl))
end
private.processingEvent = false
end
private.eventFrame = CreateFrame("Frame")
private.eventFrame:SetScript("OnEvent", private.EventHandler)
private.eventFrame:Show()

33
LibTSM/Util/FSM.lua Normal file
View File

@@ -0,0 +1,33 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- FSM Functions.
-- @module FSM
local _, TSM = ...
local FSM = TSM.Init("Util.FSM")
local Machine = TSM.Include("Util.FSMClasses.Machine")
local State = TSM.Include("Util.FSMClasses.State")
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Create a new FSM.
-- @tparam string name The name of the FSM (for debugging purposes)
-- @treturn Machine The FSM object
function FSM.New(name)
return Machine.Create(name)
end
--- Create a new FSM state.
-- @tparam string state The name of the state
-- @treturn State The State object
function FSM.NewState(state)
return State.Create(state)
end

View File

@@ -0,0 +1,182 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- FSMMachine Class.
-- This class allows implementing event-driving finite state machines.
-- @classmod FSMMachine
local _, TSM = ...
local Machine = TSM.Init("Util.FSMClasses.Machine")
local State = TSM.Include("Util.FSMClasses.State")
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local LibTSMClass = TSM.Include("LibTSMClass")
local FSMMachine = LibTSMClass.DefineClass("FSMMachine")
local private = {
eventTransitionHandlerCache = {},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function Machine.Create(name)
return FSMMachine(name)
end
-- ============================================================================
-- Class Meta Methods
-- ============================================================================
function FSMMachine.__init(self, name)
self._name = name
self._currentState = nil
self._context = nil
self._loggingDisabledCount = 0
self._stateObjs = {}
self._defaultEvents = {}
self._handlingEvent = nil
end
-- ============================================================================
-- Public Class Methods
-- ============================================================================
--- Add an FSM state.
-- @tparam FSM self The FSM object
-- @tparam FSMState stateObj The FSM state object to add
-- @treturn FSM The FSM object
function FSMMachine.AddState(self, stateObj)
assert(State.IsInstance(stateObj))
local name = stateObj:_GetName()
assert(not self._stateObjs[name], "state already exists")
self._stateObjs[stateObj:_GetName()] = stateObj
return self
end
--- Add a default event handler.
-- @tparam FSM self The FSM object
-- @tparam string event The event name
-- @tparam function handler The default event handler
-- @treturn FSM The FSM object
function FSMMachine.AddDefaultEvent(self, event, handler)
assert(not self._defaultEvents[event], "event already exists")
self._defaultEvents[event] = handler
return self
end
--- Add a simple default event-based transition.
-- @tparam FSMMachine self The FSMMachine object
-- @tparam string event The event name
-- @tparam string toState The state to transition to
-- @treturn FSMMachine The FSMMachine object
function FSMMachine.AddDefaultEventTransition(self, event, toState)
if not private.eventTransitionHandlerCache[toState] then
private.eventTransitionHandlerCache[toState] = function(context, ...)
return toState, ...
end
end
return self:AddDefaultEvent(event, private.eventTransitionHandlerCache[toState])
end
--- Initialize the FSM.
-- @tparam FSM self The FSM object
-- @tparam string initialState The name of the initial state
-- @param[opt={}] context The FSM context table which gets passed to all state and event handlers
-- @treturn FSM The FSM object
function FSMMachine.Init(self, initialState, context)
assert(self._stateObjs[initialState], "invalid initial state")
self._currentState = initialState
self._context = context or {}
-- validate all the transitions
for name, obj in pairs(self._stateObjs) do
for _, toState in obj:_ToStateIterator() do
assert(self._stateObjs[toState], format("toState doesn't exist (%s -> %s)", name, toState))
end
end
return self
end
--- Process an event.
-- @tparam FSM self The FSM object
-- @tparam string event The name of the event
-- @tparam[opt] vararg ... Additional arguments to pass to the handler function
-- @treturn FSM The FSM object
function FSMMachine.ProcessEvent(self, event, ...)
assert(self._currentState, "FSM not initialized")
if self._handlingEvent then
Log.RaiseStackLevel()
Log.Warn("[%s] %s (ignored - handling event - %s)", self._name, event, self._handlingEvent)
Log.LowerStackLevel()
return self
elseif self._inTransition then
Log.RaiseStackLevel()
Log.Warn("[%s] %s (ignored - in transition)", self._name, event)
Log.LowerStackLevel()
return self
end
if self._loggingDisabledCount == 0 then
Log.RaiseStackLevel()
Log.Info("[%s] %s", self._name, event)
Log.LowerStackLevel()
end
self._handlingEvent = event
local currentStateObj = self._stateObjs[self._currentState]
if currentStateObj:_HasEventHandler(event) then
self:_Transition(TempTable.Acquire(currentStateObj:_ProcessEvent(event, self._context, ...)))
elseif self._defaultEvents[event] then
self:_Transition(TempTable.Acquire(self._defaultEvents[event](self._context, ...)))
end
self._handlingEvent = nil
return self
end
--- Enable or disable event and state transition logs (can be called recursively).
-- @tparam FSM self The FSM object
-- @tparam boolean enabled Whether or not logging should be enabled
-- @treturn FSM The FSM object
function FSMMachine.SetLoggingEnabled(self, enabled)
self._loggingDisabledCount = self._loggingDisabledCount + (enabled and -1 or 1)
assert(self._loggingDisabledCount >= 0)
return self
end
-- ============================================================================
-- Private Class Methods
-- ============================================================================
function FSMMachine._Transition(self, eventResult)
local result = eventResult
while result[1] do
-- perform the transition
local currentStateObj = self._stateObjs[self._currentState]
local toState = tremove(result, 1)
local toStateObj = self._stateObjs[toState]
if self._loggingDisabledCount == 0 then
Log.RaiseStackLevel()
Log.RaiseStackLevel()
Log.Info("[%s] %s -> %s", self._name, self._currentState, toState)
Log.LowerStackLevel()
Log.LowerStackLevel()
end
assert(toStateObj and currentStateObj:_IsTransitionValid(toState), "invalid transition")
self._inTransition = true
currentStateObj:_Exit(self._context)
self._currentState = toState
result = TempTable.Acquire(toStateObj:_Enter(self._context, TempTable.UnpackAndRelease(result)))
self._inTransition = false
end
TempTable.Release(result)
end

View File

@@ -0,0 +1,157 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- FSMState Class.
-- This class represents a single state within an @{FSMMachine}.
-- @classmod FSMState
local _, TSM = ...
local State = TSM.Init("Util.FSMClasses.State")
local LibTSMClass = TSM.Include("LibTSMClass")
local TempTable = TSM.Include("Util.TempTable")
local FSMState = LibTSMClass.DefineClass("FSMState")
local private = {
eventTransitionHandlerCache = {},
}
-- ============================================================================
-- Class Meta Methods
-- ============================================================================
function State.Create(name)
return FSMState(name)
end
function State.IsInstance(obj)
return obj:__isa(FSMState)
end
-- ============================================================================
-- Class Meta Methods
-- ============================================================================
function FSMState.__init(self, name)
self._name = name
self._onEnterHandler = nil
self._onExitHandler = nil
self._transitionValid = {}
self._events = {}
end
-- ============================================================================
-- Public Class Methods
-- ============================================================================
--- Set the OnEnter handler.
-- This function is called upon entering the state.
-- @tparam FSMState self The FSM state object
-- @tparam ?function|string handler The handler function or a method name to call on the context object
-- @treturn FSMState The FSM state object
function FSMState.SetOnEnter(self, handler)
assert(type(handler) == "function" or type(handler) == "string")
self._onEnterHandler = handler
return self
end
--- Set the OnExit handler.
-- This function is called upon existing the state.
-- @tparam FSMState self The FSM state object
-- @tparam ?function|string handler The handler function or a method name to call on the context object
-- @treturn FSMState The FSM state object
function FSMState.SetOnExit(self, handler)
assert(type(handler) == "function" or type(handler) == "string")
self._onExitHandler = handler
return self
end
--- Add a transition.
-- @tparam FSMState self The FSM state object
-- @tparam string toState The state this transition goes to
-- @treturn FSMState The FSM state object
function FSMState.AddTransition(self, toState)
assert(not self._transitionValid[toState], "transition already exists")
self._transitionValid[toState] = true
return self
end
--- Add a handled event.
-- @tparam FSMState self The FSM state object
-- @tparam string event The name of the event
-- @tparam function handler The function called when the event occurs
-- @treturn FSMState The FSM state object
function FSMState.AddEvent(self, event, handler)
assert(not self._events[event], "event already exists")
self._events[event] = handler
return self
end
--- Add a simple event-based transition.
-- @tparam FSMState self The FSM state object
-- @tparam string event The event name
-- @tparam string toState The state to transition to
-- @treturn FSMState The FSM state object
function FSMState.AddEventTransition(self, event, toState)
if not private.eventTransitionHandlerCache[toState] then
private.eventTransitionHandlerCache[toState] = function(context, ...)
return toState, ...
end
end
return self:AddEvent(event, private.eventTransitionHandlerCache[toState])
end
-- ============================================================================
-- Private Class Methods
-- ============================================================================
function FSMState._GetName(self)
return self._name
end
function FSMState._ToStateIterator(self)
local temp = TempTable.Acquire()
for toState in pairs(self._transitionValid) do
tinsert(temp, toState)
end
return TempTable.Iterator(temp)
end
function FSMState._IsTransitionValid(self, toState)
return self._transitionValid[toState]
end
function FSMState._HasEventHandler(self, event)
return self._events[event] and true or false
end
function FSMState._ProcessEvent(self, event, context, ...)
return self:_HandlerHelper(self._events[event], context, ...)
end
function FSMState._Enter(self, context, ...)
return self:_HandlerHelper(self._onEnterHandler, context, ...)
end
function FSMState._Exit(self, context)
return self:_HandlerHelper(self._onExitHandler, context)
end
function FSMState._HandlerHelper(self, handler, context, ...)
if type(handler) == "function" then
return handler(context, ...)
elseif type(handler) == "string" then
return context[handler](context, ...)
elseif handler ~= nil then
error("Invalid handler: "..tostring(handler))
end
end

View File

@@ -0,0 +1,94 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- FontObject Functions.
-- @module FontObject
local _, TSM = ...
local FontObject = TSM.Init("Util.FontObject")
local private = {
context = {},
}
-- ============================================================================
-- Metatable
-- ============================================================================
local FONT_OBJECT_MT = {
__index = {
SetPath = function(self, path)
assert(type(path) == "string")
local context = private.context[self]
context.path = path
return self
end,
SetSize = function(self, size)
assert(type(size) == "number")
local context = private.context[self]
context.size = size
return self
end,
SetLineHeight = function(self, lineHeight)
assert(type(lineHeight) == "number")
local context = private.context[self]
context.lineHeight = lineHeight
return self
end,
GetWowFont = function(self)
local context = private.context[self]
-- wow renders the font slightly bigger than the designs would indicate, so subtract one from the font height
if context.path == "Fonts\\ARKai_C.ttf" then
-- this font is a bit smaller than it should be, so increase it by 1
return context.path, context.size + 1
else
-- wow renders other fonts slightly bigger than the designs would indicate, so decrease the height by 1
return context.path, context.size - 1
end
end,
GetSpacing = function(self)
local context = private.context[self]
assert(context.lineHeight >= context.size)
return context.lineHeight - context.size
end,
},
__newindex = function(self, key, value) error("FontObject cannot be modified") end,
__metatable = false,
__tostring = function(self)
local context = private.context[self]
local shortPath = strmatch(context.path, "([^/\\]+)%.[A-Za-z]+$")
return "FontObject:"..tostring(shortPath)..":"..tostring(context.size)..":"..tostring(context.lineHeight)
end,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Create an font object from a path and height.
-- @tparam string path The path to the font file
-- @tparam number size The size of the font in pixels
-- @tparam number lineHeight The height of each line of text in pixels
-- @treturn FontObject The font object
function FontObject.New(path, size, lineHeight)
local obj = setmetatable({}, FONT_OBJECT_MT)
private.context[obj] = {
path = nil,
size = nil,
lineHeight = nil,
}
obj:SetPath(path)
obj:SetSize(size)
obj:SetLineHeight(lineHeight)
return obj
end
function FontObject.IsInstance(obj)
return type(obj) == "table" and private.context[obj] and true or false
end

110
LibTSM/Util/Future.lua Normal file
View File

@@ -0,0 +1,110 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Future Functions.
-- @module Future
local _, TSM = ...
local Future = TSM.Init("Util.Future")
local private = {
context = {},
}
-- ============================================================================
-- Metatable
-- ============================================================================
local FUTURE_MT = {
__index = {
GetName = function(self)
local context = private.context[self]
return context.name
end,
SetScript = function(self, script, handler)
assert(type(handler) == "function")
local context = private.context[self]
if script == "OnDone" then
assert(context.state ~= "DONE")
assert(not context.onDone)
context.onDone = handler
elseif script == "OnCleanup" then
assert(not context.onCleanup)
context.onCleanup = handler
else
error("Unknown script: "..tostring(script))
end
end,
Start = function(self)
local context = private.context[self]
assert(context.state == "RESET")
context.state = "STARTED"
end,
Cancel = function(self)
local context = private.context[self]
assert(context.state ~= "RESET")
private.Reset(self)
end,
Done = function(self, value)
local context = private.context[self]
assert(context.state == "STARTED")
context.state = "DONE"
context.value = value
if context.onDone then
context.onDone(self)
end
end,
IsDone = function(self)
local context = private.context[self]
assert(context.state ~= "RESET")
return context.state == "DONE"
end,
GetValue = function(self)
local context = private.context[self]
assert(context.state == "DONE")
local value = context.value
private.Reset(self)
return value
end,
},
__newindex = function(self, key, value) error("Future cannot be modified") end,
__metatable = false,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Create a new future.
-- @tparam string name The name of the future for debugging purposes
-- @treturn Future The future object
function Future.New(name)
local future = setmetatable({}, FUTURE_MT)
private.context[future] = {
name = name,
}
private.Reset(future)
return future
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.Reset(future)
local context = private.context[future]
context.state = "RESET"
context.value = nil
context.onDone = nil
if context.onCleanup then
context.onCleanup()
end
end

253
LibTSM/Util/HSLuv.lua Normal file
View File

@@ -0,0 +1,253 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
-- NOTE: The following code is heavily based on https://github.com/hsluv/hsluv-lua, with some
-- modifications to work properly with TSM. Its original license is below:
--[[
Lua implementation of HSLuv and HPLuv color spaces
Homepage: http://www.hsluv.org/
Copyright (C) 2019 Alexei Boronine
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
local _, TSM = ...
local HSLuv = TSM.Init("Util.HSLuv")
local Math = TSM.Include("Util.Math")
local private = {}
local M = {
{ 3.240969941904521, -1.537383177570093, -0.498610760293 },
{ -0.96924363628087, 1.87596750150772, 0.041555057407175 },
{ 0.055630079696993, -0.20397695888897, 1.056971514242878 }
}
local M_INV = {
{ 0.41239079926595, 0.35758433938387, 0.18048078840183 },
{ 0.21263900587151, 0.71516867876775, 0.072192315360733 },
{ 0.019330818715591, 0.11919477979462, 0.95053215224966 }
}
local REF_Y = 1.0
local REF_U = 0.19783000664283
local REF_V = 0.46831999493879
local KAPPA = 903.2962962
local EPSILON = 0.0088564516
-- ============================================================================
-- Module Functions
-- ============================================================================
function HSLuv.ToRGB(h, s, l)
return private.HSLuvToRGB(h, s, l)
end
function HSLuv.FromRGB(r, g, b)
return private.RGBToHSLuv(r, g, b)
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.MaxSafeChromaForLH(l, h)
local hrad = h / 360 * math.pi * 2
local chroma = 1.7976931348623157e+308
local sub1 = ((l + 16) ^ 3) / 1560896
local sub2 = nil
if sub1 > EPSILON then
sub2 = sub1
else
sub2 = l / KAPPA
end
for i = 1, 3 do
for t = 0, 1 do
local top1 = (284517 * M[i][1] - 94839 * M[i][3]) * sub2
local top2 = (838422 * M[i][3] + 769860 * M[i][2] + 731718 * M[i][1]) * l * sub2 - 769860 * t * l
local bottom = (632260 * M[i][3] - 126452 * M[i][2]) * sub2 + 126452 * t
if bottom ~= 0 then
local slope = top1 / bottom
local intercept = top2 / bottom
if hrad ~= 0 or slope ~= 0 then
local length = intercept / (math.sin(hrad) - slope * math.cos(hrad))
if length >= 0 then
chroma = min(chroma, length)
end
end
end
end
end
return chroma
end
function private.DotProduct(a, b1, b2, b3)
return a[1] * b1 + a[2] * b2 + a[3] * b3
end
function private.FromLinear(c)
if c <= 0.0031308 then
return 12.92 * c
else
return 1.055 * (c ^ 0.416666666666666685) - 0.055
end
end
function private.ToLinear(c)
if c > 0.04045 then
return ((c + 0.055) / 1.055) ^ 2.4
else
return c / 12.92
end
end
function private.XYZToRGB(x, y, z)
local r = private.FromLinear(private.DotProduct(M[1], x, y, z))
local g = private.FromLinear(private.DotProduct(M[2], x, y, z))
local b = private.FromLinear(private.DotProduct(M[3], x, y, z))
return r, g, b
end
function private.RGBToXYZ(r, g, b)
r = private.ToLinear(r)
g = private.ToLinear(g)
b = private.ToLinear(b)
local x = private.DotProduct(M_INV[1], r, g, b)
local y = private.DotProduct(M_INV[2], r, g, b)
local z = private.DotProduct(M_INV[3], r, g, b)
return x, y, z
end
function private.YToL(Y)
if Y <= EPSILON then
return Y / REF_Y * KAPPA
else
return 116 * ((Y / REF_Y) ^ 0.333333333333333315) - 16
end
end
function private.LToY(L)
if L <= 8 then
return REF_Y * L / KAPPA
else
return REF_Y * (((L + 16) / 116) ^ 3)
end
end
function private.XYZToLUV(x, y, z)
local divider = x + 15 * y + 3 * z
local varU = 4 * x
local varV = 9 * y
if divider ~= 0 then
varU = varU / divider
varV = varV / divider
else
varU = 0
varV = 0
end
local L = private.YToL(y)
if L == 0 then
return 0, 0, 0
end
return L, 13 * L * (varU - REF_U), 13 * L * (varV - REF_V)
end
function private.LUVToXYZ(l, u, v)
if l == 0 then
return 0, 0, 0
end
local varU = u / (13 * l) + REF_U
local varV = v / (13 * l) + REF_V
local Y = private.LToY(l)
local X = 0 - (9 * Y * varU) / ((((varU - 4) * varV) - varU * varV))
return X, Y, (9 * Y - 15 * varV * Y - varV * X) / (3 * varV)
end
function private.LUVToLCH(l, u, v)
local C = math.sqrt(u * u + v * v)
local H
if C < 0.00000001 then
H = 0
else
H = math.atan2(v, u) * 180.0 / 3.1415926535897932
if H < 0 then
H = 360 + H
end
end
return l, C, H
end
function private.LCHToLUV(l, c, h)
local hrad = h / 360.0 * 2 * math.pi
return l, math.cos(hrad) * c, math.sin(hrad) * c
end
function private.HSLuvToLCH(h, s, l)
if l > 99.9999999 then
return 100, 0, h
end
if l < 0.00000001 then
return 0, 0, h
end
return l, private.MaxSafeChromaForLH(l, h) / 100 * s, h
end
function private.LCHToHSLuv(l, c, h)
local max_chroma = private.MaxSafeChromaForLH(l, h)
if l > 99.9999999 then
return h, 0, 100
end
if l < 0.00000001 then
return h, 0, 0
end
return h, c / max_chroma * 100, l
end
function private.HSLuvToRGB(h, s, l)
local v1, v2, v3 = h, s, l
v1, v2, v3 = private.HSLuvToLCH(v1, v2, v3)
v1, v2, v3 = private.LCHToLUV(v1, v2, v3)
v1, v2, v3 = private.LUVToXYZ(v1, v2, v3)
local r, g, b = private.XYZToRGB(v1, v2, v3)
r = Math.Round(r * 255)
g = Math.Round(g * 255)
b = Math.Round(b * 255)
assert(r >= 0 and r <= 255)
assert(g >= 0 and g <= 255)
assert(b >= 0 and b <= 255)
return r, g, b
end
function private.RGBToHSLuv(r, g, b)
local v1, v2, v3 = r / 255, g / 255, b / 255
v1, v2, v3 = private.RGBToXYZ(v1, v2, v3)
v1, v2, v3 = private.XYZToLUV(v1, v2, v3)
v1, v2, v3 = private.LUVToLCH(v1, v2, v3)
local h, s, l = private.LCHToHSLuv(v1, v2, v3)
h = Math.Round(h) % 360
s = Math.Round(s)
l = Math.Round(l)
assert(h >= 0 and h < 360)
assert(s >= 0 and s <= 100)
assert(l >= 0 and l <= 100)
return h, s, l
end

328
LibTSM/Util/ItemString.lua Normal file
View File

@@ -0,0 +1,328 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Item String functions
-- @module ItemString
local _, TSM = ...
local ItemString = TSM.Init("Util.ItemString")
local BonusIds = TSM.Include("Data.BonusIds")
local SmartMap = TSM.Include("Util.SmartMap")
local private = {
filteredItemStringCache = {},
itemStringCache = {},
baseItemStringMap = nil,
baseItemStringReader = nil,
hasNonBaseItemStrings = {},
bonusIdsTemp = {},
modifiersTemp = {},
}
local ITEM_MAX_ID = 999999
local UNKNOWN_ITEM_STRING = "i:0"
local PLACEHOLDER_ITEM_STRING = "i:1"
local PET_CAGE_ITEM_STRING = "i:82800"
local MINIMUM_VARIANT_ITEM_ID = 152632
local IMPORTANT_MODIFIER_TYPES = {
[9] = true,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
ItemString:OnModuleLoad(function()
private.baseItemStringMap = SmartMap.New("string", "string", private.ToBaseItemString)
private.baseItemStringReader = private.baseItemStringMap:CreateReader()
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Gets the constant unknown item string for places where the itemString is not known.
-- @treturn string The itemString
function ItemString.GetUnknown()
return UNKNOWN_ITEM_STRING
end
--- Gets the constant placeholder item string.
-- @treturn string The itemString
function ItemString.GetPlaceholder()
return PLACEHOLDER_ITEM_STRING
end
--- Gets the battlepet cage item string.
-- @treturn string The itemString
function ItemString.GetPetCage()
return PET_CAGE_ITEM_STRING
end
--- Gets the base itemString smart map.
-- @treturn SmartMap The smart map
function ItemString.GetBaseMap()
return private.baseItemStringMap
end
--- Converts the parameter into an itemString.
-- @tparam ?number|string item Either an itemId, itemLink, or itemString to be converted
-- @treturn string The itemString
function ItemString.Get(item)
if not item then
return nil
end
if not private.itemStringCache[item] then
private.itemStringCache[item] = private.ToItemString(item)
end
return private.itemStringCache[item]
end
function ItemString.Filter(itemString)
if not private.filteredItemStringCache[itemString] then
private.filteredItemStringCache[itemString] = private.FilterBonusIdsAndModifiers(itemString, true, strsplit(":", itemString))
end
return private.filteredItemStringCache[itemString]
end
--- Converts the parameter into an itemId.
-- @tparam string item An item to get the id of
-- @treturn number The itemId
function ItemString.ToId(item)
local itemString = ItemString.Get(item)
if type(itemString) ~= "string" then
return
end
return tonumber(strmatch(itemString, "^[ip]:(%d+)"))
end
--- Converts the parameter into a base itemString.
-- @tparam string itemString An itemString to get the base itemString of
-- @treturn string The base itemString
function ItemString.GetBaseFast(itemString)
if not itemString then
return nil
end
return private.baseItemStringReader[itemString]
end
--- Converts the parameter into a base itemString.
-- @tparam string item An item to get the base itemString of
-- @treturn string The base itemString
function ItemString.GetBase(item)
-- make sure it's a valid itemString
local itemString = ItemString.Get(item)
if not itemString then return end
-- quickly return if we're certain it's already a valid baseItemString
if type(itemString) == "string" and strmatch(itemString, "^[ip]:[0-9]+$") then return itemString end
return ItemString.GetBaseFast(itemString)
end
--- Converts an itemKey from WoW into a base itemString.
-- @tparam table itemKey An itemKey to get the itemString of
-- @treturn string The base itemString
function ItemString.GetBaseFromItemKey(itemKey)
if itemKey.battlePetSpeciesID > 0 then
return "p:"..itemKey.battlePetSpeciesID
else
return "i:"..itemKey.itemID
end
end
function ItemString.HasNonBase(baseItemString)
return private.hasNonBaseItemStrings[baseItemString] or false
end
--- Converts the parameter into a WoW itemString.
-- @tparam string itemString An itemString to get the WoW itemString of
-- @treturn number The WoW itemString
function ItemString.ToWow(itemString)
local _, itemId, rand, extra = strsplit(":", itemString)
local level = UnitLevel("player")
local spec = not TSM.IsWowClassic() and GetSpecialization() or nil
spec = spec and GetSpecializationInfo(spec) or ""
local extraPart = extra and strmatch(itemString, "i:[0-9]+:[0-9%-]*:(.+)") or ""
return "item:"..itemId.."::::::"..(rand or "").."::"..level..":"..spec..":::"..extraPart..":::"
end
function ItemString.IsItem(itemString)
return strmatch(itemString, "^i:[%-:0-9]+$") and true or false
end
function ItemString.IsPet(itemString)
return strmatch(itemString, "^p:[%-:0-9]+$") and true or false
end
-- ============================================================================
-- Helper Functions
-- ============================================================================
function private.ToItemString(item)
local paramType = type(item)
if paramType == "string" then
item = strtrim(item)
local itemId = strmatch(item, "^[ip]:([0-9]+)$")
if itemId then
if tonumber(itemId) > ITEM_MAX_ID then
return nil
end
-- this is already an itemString
return item
end
itemId = strmatch(item, "item:(%d+)")
if itemId and tonumber(itemId) > ITEM_MAX_ID then
return nil
end
elseif paramType == "number" or tonumber(item) then
local itemId = tonumber(item)
if itemId > ITEM_MAX_ID then
return nil
end
-- assume this is an itemId
return "i:"..item
else
error("Invalid item parameter type: "..tostring(item))
end
-- test if it's already (likely) an item string or battle pet string
if strmatch(item, "^i:([0-9%-:]+)$") then
return private.FixItemString(item)
elseif strmatch(item, "^p:([0-9:]+)$") then
return private.FixPet(item)
end
local result = strmatch(item, "^\124cff[0-9a-z]+\124[Hh](.+)\124h%[.+%]\124h\124r$")
if result then
-- it was a full item link which we've extracted the itemString from
item = result
end
-- test if it's an old style item string
result = strjoin(":", strmatch(item, "^(i)tem:([0-9%-]+):[0-9%-]+:[0-9%-]+:[0-9%-]+:[0-9%-]+:[0-9%-]+:([0-9%-]+)$"))
if result then
return private.FixItemString(result)
end
-- test if it's an old style battle pet string (or if it was a link)
result = strjoin(":", strmatch(item, "^battle(p)et:(%d+:%d+:%d+)"))
if result then
return private.FixPet(result)
end
result = strjoin(":", strmatch(item, "^battle(p)et:(%d+)[:]*$"))
if result then
return result
end
result = strjoin(":", strmatch(item, "^(p):(%d+:%d+:%d+)"))
if result then
return private.FixPet(result)
end
-- test if it's a long item string
result = strjoin(":", strmatch(item, "(i)tem:([0-9%-]+):[0-9%-]*:[0-9%-]*:[0-9%-]*:[0-9%-]*:[0-9%-]*:([0-9%-]*):[0-9%-]*:[0-9%-]*:[0-9%-]*:[0-9%-]*:[0-9%-]*:([0-9%-:]+)"))
if result and result ~= "" then
return private.FixItemString(result)
end
-- test if it's a shorter item string (without bonuses)
result = strjoin(":", strmatch(item, "(i)tem:([0-9%-]+):[0-9%-]*:[0-9%-]*:[0-9%-]*:[0-9%-]*:[0-9%-]*:([0-9%-]*)"))
if result and result ~= "" then
return result
end
end
function private.RemoveExtra(itemString)
local num = 1
while num > 0 do
itemString, num = gsub(itemString, ":0?$", "")
end
return itemString
end
function private.FixItemString(itemString)
itemString = gsub(itemString, ":0:", "::") -- remove 0s which are in the middle
itemString = private.RemoveExtra(itemString)
return private.FilterBonusIdsAndModifiers(itemString, false, strsplit(":", itemString))
end
function private.FixPet(itemString)
itemString = private.RemoveExtra(itemString)
local result = strmatch(itemString, "^(p:%d+:%d+:%d+)$")
if result then
return result
end
return strmatch(itemString, "^(p:%d+)")
end
function private.FilterBonusIdsAndModifiers(itemString, importantBonusIdsOnly, itemType, itemId, rand, numBonusIds, ...)
numBonusIds = tonumber(numBonusIds) or 0
local numParts = select("#", ...)
if numParts == 0 then
return itemString
end
-- grab the modifiers and filter them
local numModifiers = numParts - numBonusIds
local modifiersStr = (numModifiers > 0 and numModifiers > 1 and numModifiers % 2 == 1) and strjoin(":", select(numBonusIds + 1, ...)) or ""
if modifiersStr ~= "" then
wipe(private.modifiersTemp)
local num, modifierType = nil, nil
for modifier in gmatch(modifiersStr, "[0-9]+") do
modifier = tonumber(modifier)
if not num then
num = modifier
elseif not modifierType then
modifierType = modifier
else
if IMPORTANT_MODIFIER_TYPES[modifierType] then
tinsert(private.modifiersTemp, modifierType)
tinsert(private.modifiersTemp, modifier)
end
modifierType = nil
end
end
if #private.modifiersTemp > 0 then
assert(#private.modifiersTemp % 2 == 0)
tinsert(private.modifiersTemp, 1, #private.modifiersTemp / 2)
modifiersStr = table.concat(private.modifiersTemp, ":")
end
end
-- filter the bonusIds
local bonusIdsStr = ""
if numBonusIds > 0 then
-- get the list of bonusIds and filter them
wipe(private.bonusIdsTemp)
for i = 1, numBonusIds do
private.bonusIdsTemp[i] = select(i, ...)
end
if importantBonusIdsOnly then
-- Only track bonusIds if the itemId is above our minimum
if tonumber(itemId) >= MINIMUM_VARIANT_ITEM_ID then
bonusIdsStr = BonusIds.FilterImportant(table.concat(private.bonusIdsTemp, ":"))
end
else
bonusIdsStr = BonusIds.FilterAll(table.concat(private.bonusIdsTemp, ":"))
end
end
-- rebuild the itemString
itemString = strjoin(":", itemType, itemId, rand, bonusIdsStr, modifiersStr)
itemString = gsub(itemString, ":0:", "::") -- remove 0s which are in the middle
return private.RemoveExtra(itemString)
end
function private.ToBaseItemString(itemString)
local baseItemString = strmatch(itemString, "[ip]:%d+")
if baseItemString ~= itemString then
private.hasNonBaseItemStrings[baseItemString] = true
end
return baseItemString
end

58
LibTSM/Util/JSON.lua Normal file
View File

@@ -0,0 +1,58 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- JSON Functions
-- @module JSON
local _, TSM = ...
local JSON = TSM.Init("Util.JSON")
local private = {}
-- ============================================================================
-- Module Functions
-- ============================================================================
function JSON.Encode(value)
if type(value) == "string" then
return "\""..private.SanitizeString(value).."\""
elseif type(value) == "number" or type(value) == "boolean" then
return tostring(value)
elseif type(value) == "table" then
local absCount = 0
for _ in pairs(value) do
absCount = absCount + 1
end
local tblParts = {}
if #value == absCount then
for _, v in ipairs(value) do
tinsert(tblParts, JSON.Encode(v))
end
return "["..table.concat(tblParts, ",").."]"
else
for k, v in pairs(value) do
tinsert(tblParts, "\""..private.SanitizeString(k).."\":"..JSON.Encode(v))
end
return "{"..table.concat(tblParts, ",").."}"
end
else
error("Invalid type: "..type(value))
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.SanitizeString(str)
str = gsub(str, "\124cff[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]([^\124]+)\124r", "%1")
str = gsub(str, "[\\]+", "/")
str = gsub(str, "\"", "'")
return str
end

189
LibTSM/Util/Log.lua Normal file
View File

@@ -0,0 +1,189 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Log = TSM.Init("Util.Log")
local Debug = TSM.Include("Util.Debug")
local Theme = TSM.Include("Util.Theme")
local private = {
severity = {},
location = {},
timeStr = {},
msg = {},
writeIndex = 1,
len = 0,
temp = {},
logToChat = TSM.__IS_TEST_ENV or false,
currentThreadNameFunc = nil,
stackLevel = 3,
chatFrame = nil,
}
local MAX_ROWS = 200
local MAX_MSG_LEN = 150
local CHAT_LOG_COLOR_KEYS = {
TRACE = "BLUE",
INFO = "GREEN",
WARN = "YELLOW",
ERR = "RED",
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function Log.SetChatFrame(chatFrame)
private.chatFrame = strlower(chatFrame)
end
function Log.SetLoggingToChatEnabled(enabled)
if TSM.__IS_TEST_ENV then
enabled = true
end
if private.logToChat == enabled then
return
end
private.logToChat = enabled
if enabled then
-- dump our buffer
local len = Log.Length()
print(format("Printing %d buffered logs:", len))
for i = 1, len do
private.LogToChat(Log.Get(i))
end
end
end
function Log.SetCurrentThreadNameFunction(func)
private.currentThreadNameFunc = func
end
function Log.Length()
return private.len
end
function Log.Get(index)
assert(index <= private.len)
local readIndex = (private.writeIndex - private.len + index - 2) % MAX_ROWS + 1
return private.severity[readIndex], private.location[readIndex], private.timeStr[readIndex], private.msg[readIndex]
end
function Log.RaiseStackLevel()
private.stackLevel = private.stackLevel + 1
end
function Log.LowerStackLevel()
private.stackLevel = private.stackLevel - 1
end
function Log.StackTrace()
Log.RaiseStackLevel()
Log.Trace("Stack Trace:")
local level = 2
local line = Debug.GetStackLevelLocation(level)
while line do
Log.Trace(" " .. line)
level = level + 1
line = Debug.GetStackLevelLocation(level)
end
Log.LowerStackLevel()
end
function Log.Trace(...)
private.Log("TRACE", ...)
end
function Log.Info(...)
private.Log("INFO", ...)
end
function Log.Warn(...)
private.Log("WARN", ...)
end
function Log.Err(...)
private.Log("ERR", ...)
end
function Log.PrintUserRaw(str)
private.GetChatFrame():AddMessage(str)
end
function Log.PrintfUserRaw(...)
Log.PrintUserRaw(format(...))
end
function Log.PrintUser(str)
Log.PrintUserRaw(Theme.GetColor("INDICATOR"):ColorText("TSM")..": "..str)
end
function Log.PrintfUser(...)
Log.PrintUser(format(...))
end
function Log.ColorUserAccentText(text)
return Theme.GetColor("INDICATOR_ALT"):ColorText(text)
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetChatFrame()
for i = 1, NUM_CHAT_WINDOWS do
local name = strlower(GetChatWindowInfo(i) or "")
if name ~= "" and name == private.chatFrame then
return _G["ChatFrame" .. i]
end
end
return DEFAULT_CHAT_FRAME
end
function private.Log(severity, fmtStr, ...)
assert(type(fmtStr) == "string" and CHAT_LOG_COLOR_KEYS[severity])
wipe(private.temp)
for i = 1, select("#", ...) do
local arg = select(i, ...)
if type(arg) == "boolean" then
arg = arg and "T" or "F"
elseif type(arg) ~= "string" and type(arg) ~= "number" then
arg = tostring(arg)
end
private.temp[i] = arg
end
-- ignore anything after a newline in the log message
local msg = strsplit("\n", format(fmtStr, unpack(private.temp)))
if #msg > MAX_MSG_LEN then
msg = strsub(msg, 1, -4).."..."
end
local location = Debug.GetStackLevelLocation(private.stackLevel)
location = location and strmatch(location, "([^\\/]+%.lua:[0-9]+)") or "?:?"
local threadName = private.currentThreadNameFunc and private.currentThreadNameFunc() or nil
if threadName then
location = location.."|"..threadName
end
local timeMs = Debug.GetTimeMilliseconds()
local timeStr = format("%s.%03d", date("%H:%M:%S", floor(timeMs / 1000)), timeMs % 1000)
-- append the log
private.severity[private.writeIndex] = severity
private.location[private.writeIndex] = location
private.timeStr[private.writeIndex] = timeStr
private.msg[private.writeIndex] = msg
private.writeIndex = (private.writeIndex < MAX_ROWS) and (private.writeIndex + 1) or 1
private.len = min(private.len + 1, MAX_ROWS)
if private.logToChat then
private.LogToChat(severity, location, timeStr, msg)
end
end
function private.LogToChat(severity, location, timeStr, msg)
print(strjoin(" ", timeStr, Theme.GetFeedbackColor(CHAT_LOG_COLOR_KEYS[severity]):ColorText("{"..location.."}"), msg))
end

162
LibTSM/Util/Math.lua Normal file
View File

@@ -0,0 +1,162 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Math Functions
-- @module Math
local _, TSM = ...
local Math = TSM.Init("Util.Math")
local TempTable = TSM.Include("Util.TempTable")
local NAN = math.huge * 0
local IS_NAN_GT_INF = NAN > math.huge
local NAN_STR = tostring(NAN)
local private = {
keysTemp = {},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Returns NAN.
-- @treturn number NAN
function Math.GetNan()
return NAN
end
--- Checks if a value is NAN.
-- @tparam number value The number to check
-- @treturn boolean Whether or not the value is NAN
function Math.IsNan(value)
if IS_NAN_GT_INF then
-- optimization if NAN > math.huge (which it is in Wow's version of lua)
return value > math.huge and tostring(value) == NAN_STR
else
return tostring(value) == NAN_STR
end
end
--- Rounds a value to a specified significant value.
-- @tparam number value The number to be rounded
-- @tparam number sig The value to round to the nearest multiple of
-- @treturn number The rounded value
function Math.Round(value, sig)
sig = sig or 1
return floor((value / sig) + 0.5) * sig
end
--- Rounds a value down to a specified significant value.
-- @tparam number value The number to be rounded
-- @tparam number sig The value to round down to the nearest multiple of
-- @treturn number The rounded value
function Math.Floor(value, sig)
sig = sig or 1
return floor(value / sig) * sig
end
--- Rounds a value up to a specified significant value.
-- @tparam number value The number to be rounded
-- @tparam number sig The value to round up to the nearest multiple of
-- @treturn number The rounded value
function Math.Ceil(value, sig)
sig = sig or 1
return ceil(value / sig) * sig
end
--- Scales a value from one range to another.
-- @tparam number value The number to be scaled
-- @tparam number fromStart The start value of the range to scale from
-- @tparam number fromEnd The end value of the range to scale from (can be less than fromStart)
-- @tparam number toStart The start value of the range to scale to
-- @tparam number toEnd The end value of the range to scale to (can be less than toStart)
-- @treturn number The scaled value
function Math.Scale(value, fromStart, fromEnd, toStart, toEnd)
assert(value >= min(fromStart, fromEnd) and value <= max(fromStart, fromEnd))
return toStart + ((value - fromStart) / (fromEnd - fromStart)) * (toEnd - toStart)
end
--- Bounds a number between a min and max value.
-- @tparam number value The number to be bounded
-- @tparam number minValue The min value
-- @tparam number maxValue The max value
-- @treturn number The bounded value
function Math.Bound(value, minValue, maxValue)
return min(max(value, minValue), maxValue)
end
--- Calculates the has of the specified data
-- This data can handle data of type string or number. It can also handle a table being passed as the data assuming
-- all keys and values of the table are also hashable (strings, numbers, or tables with the same restriction). This
-- function uses the [djb2 algorithm](http://www.cse.yorku.ca/~oz/hash.html).
-- @param data The data to be hased
-- @tparam[opt] number hash The initial value of the hash
-- @treturn number The hash value
function Math.CalculateHash(data, hash)
hash = hash or 5381
local maxValue = 2 ^ 24
local dataType = type(data)
if dataType == "string" then
-- iterate through 8 bytes at a time
for i = 1, ceil(#data / 8) do
local b1, b2, b3, b4, b5, b6, b7, b8 = strbyte(data, (i - 1) * 8 + 1, i * 8)
hash = (hash * 33 + b1) % maxValue
if not b2 then break end
hash = (hash * 33 + b2) % maxValue
if not b3 then break end
hash = (hash * 33 + b3) % maxValue
if not b4 then break end
hash = (hash * 33 + b4) % maxValue
if not b5 then break end
hash = (hash * 33 + b5) % maxValue
if not b6 then break end
hash = (hash * 33 + b6) % maxValue
if not b7 then break end
hash = (hash * 33 + b7) % maxValue
if not b8 then break end
hash = (hash * 33 + b8) % maxValue
end
elseif dataType == "number" then
assert(data == floor(data), "Invalid number")
if data < 0 then
data = data * -1
hash = (hash * 33 + 59) % maxValue
end
while data > 0 do
hash = (hash * 33 + data % 256) % maxValue
data = floor(data / 256)
end
elseif dataType == "table" then
local keys = nil
if private.keysTemp.inUse then
keys = TempTable.Acquire()
else
keys = private.keysTemp
private.keysTemp.inUse = true
end
for k in pairs(data) do
tinsert(keys, k)
end
sort(keys)
for _, key in ipairs(keys) do
hash = Math.CalculateHash(key, hash)
hash = Math.CalculateHash(data[key], hash)
end
if keys == private.keysTemp then
wipe(private.keysTemp)
else
TempTable.Release(keys)
end
elseif dataType == "boolean" then
hash = (hash * 33 + (data and 1 or 0)) % maxValue
elseif dataType == "nil" then
hash = (hash * 33 + 17) % maxValue
else
error("Invalid data")
end
return hash
end

177
LibTSM/Util/Money.lua Normal file
View File

@@ -0,0 +1,177 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Money Functions
-- @module Money
local _, TSM = ...
local Money = TSM.Init("Util.Money")
local String = TSM.Include("Util.String")
local private = {
textMoneyParts = {},
}
local GOLD_ICON = "|TInterface\\MoneyFrame\\UI-GoldIcon:0|t"
local SILVER_ICON = "|TInterface\\MoneyFrame\\UI-SilverIcon:0|t"
local COPPER_ICON = "|TInterface\\MoneyFrame\\UI-CopperIcon:0|t"
local GOLD_ICON_DISABLED = "|TInterface\\MoneyFrame\\UI-GoldIcon:0:0:0:0:1:1:0:1:0:1:100:100:100|t"
local SILVER_ICON_DISABLED = "|TInterface\\MoneyFrame\\UI-SilverIcon:0:0:0:0:1:1:0:1:0:1:100:100:100|t"
local COPPER_ICON_DISABLED = "|TInterface\\MoneyFrame\\UI-CopperIcon:0:0:0:0:1:1:0:1:0:1:100:100:100|t"
local GOLD_TEXT = "|cffffd70ag|r"
local SILVER_TEXT = "|cffc7c7cfs|r"
local COPPER_TEXT = "|cffeda55fc|r"
local GOLD_TEXT_DISABLED = "|cff5d5222g|r"
local SILVER_TEXT_DISABLED = "|cff464646s|r"
local COPPER_TEXT_DISABLED = "|cff402d22c|r"
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Converts a numeric money value (in copper) to a string for display in the UI.
-- Supported options:
--
-- * OPT\_ICON Use texture icons instead of g/s/c letters
-- * OPT\_TRIM Remove any non-significant 0 valued denominations (i.e. "1g" instead of "1g 0s 0c")
-- * OPT\_83\_NO\_COPPER Remove the copper value entirely if we're patch 8.3
-- * OPT\_DISABLE Uses a muted color from the denomination text (not allowed with "OPT\_ICON" or "OPT\_NO\_COLOR")
-- @tparam number value The money value to be converted in copper (100 copper per silver, 100 silver per gold)
-- @tparam[opt] string color A color prefix to use for the numbers in the result (i.e. "|cff00ff00" for red)
-- @param[opt] ... One or more options to modify the format of the result
-- @return The string representation of the specified money value
function Money.ToString(value, color, ...)
value = tonumber(value)
if not value then
return
end
assert(not color or strmatch(color, "^\124cff[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]$"))
-- parse the options
local isIcon, trim, disabled, noCopper = false, false, false, false
for i = 1, select('#', ...) do
local opt = select(i, ...)
if opt == nil then
-- pass
elseif opt == "OPT_ICON" then
isIcon = true
elseif opt == "OPT_TRIM" then
trim = true
elseif opt == "OPT_DISABLE" then
disabled = true
elseif opt == "OPT_83_NO_COPPER" then
noCopper = not TSM.IsWowClassic()
else
error("Invalid option: "..tostring(opt))
end
end
local isNegative = value < 0
value = abs(value)
local gold = floor(value / COPPER_PER_GOLD)
local silver = floor((value % COPPER_PER_GOLD) / COPPER_PER_SILVER)
local copper = floor(value % COPPER_PER_SILVER)
assert(not noCopper or copper == 0)
local goldText, silverText, copperText = nil, nil, nil
if isIcon then
if disabled then
goldText, silverText, copperText = GOLD_ICON_DISABLED, SILVER_ICON_DISABLED, COPPER_ICON_DISABLED
else
goldText, silverText, copperText = GOLD_ICON, SILVER_ICON, COPPER_ICON
end
else
if disabled then
goldText, silverText, copperText = GOLD_TEXT_DISABLED, SILVER_TEXT_DISABLED, COPPER_TEXT_DISABLED
else
goldText, silverText, copperText = GOLD_TEXT, SILVER_TEXT, COPPER_TEXT
end
end
if value == 0 then
return private.FormatNumber(0, true, color)..(noCopper and silverText or copperText)
end
wipe(private.textMoneyParts)
-- add gold
if gold > 0 then
private.InsertMoneyPart(gold, color, goldText)
end
-- add silver
if silver > 0 or (not trim and gold > 0) then
private.InsertMoneyPart(silver, color, silverText)
end
-- add copper
if copper > 0 or (not trim and not noCopper and (gold + silver) > 0) then
private.InsertMoneyPart(copper, color, copperText)
end
local text = table.concat(private.textMoneyParts, " ")
if isNegative then
return (color and (color.."-|r") or "-")..text
else
return text
end
end
--- Converts a string money value to a number value (in copper).
-- The value passed to this function can contain colored text, but must use g/s/c for the denominations and not icons.
-- @tparam string value The money value to be converted as a string
-- @treturn string The numeric representation of the specified money value
function Money.FromString(value)
-- remove any colors
value = gsub(gsub(strtrim(value), "\124c([0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])", ""), "\124r", "")
-- remove any separators
value = gsub(value, String.Escape(LARGE_NUMBER_SEPERATOR), "")
-- extract gold/silver/copper values
local gold = tonumber(strmatch(value, "([0-9]+)g"))
local silver = tonumber(strmatch(value, "([0-9]+)s"))
local copper = tonumber(strmatch(value, "([0-9]+)c"))
if not gold and not silver and not copper then return end
-- test that there are no extra characters (other than spaces)
value = gsub(value, "[0-9]+g", "", 1)
value = gsub(value, "[0-9]+s", "", 1)
value = gsub(value, "[0-9]+c", "", 1)
if strtrim(value) ~= "" then return end
return ((gold or 0) * COPPER_PER_GOLD) + ((silver or 0) * COPPER_PER_SILVER) + (copper or 0)
end
--- Returns the colored gold indicator text
-- @treturn string The colored gold indicator text
function Money.GetGoldText()
return GOLD_TEXT
end
-- ============================================================================
-- Helper Functions
-- ============================================================================
function private.InsertMoneyPart(value, color, text)
tinsert(private.textMoneyParts, private.FormatNumber(value, #private.textMoneyParts == 0, color)..text)
end
function private.FormatNumber(num, isMostSignificant, color)
if num < 10 and not isMostSignificant then
num = "0"..num
elseif isMostSignificant and num >= 1000 then
num = tostring(num)
local result = ""
for i = 4, #num, 3 do
result = LARGE_NUMBER_SEPERATOR..strsub(num, -(i - 1), -(i - 3))..result
end
result = strsub(num, 1, (#num % 3 == 0) and 3 or (#num % 3))..result
num = result
end
if color then
return color..num.."|r"
else
return num
end
end

215
LibTSM/Util/NineSlice.lua Normal file
View File

@@ -0,0 +1,215 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- NineSlice Functions.
-- @module NineSlice
local _, TSM = ...
local NineSlice = TSM.Init("Util.NineSlice")
local private = {
styles = {},
context = {},
}
local PART_INFO = {
topLeft = {
points = {
{ "TOPLEFT" },
},
},
bottomLeft = {
points = {
{ "BOTTOMLEFT" },
},
},
topRight = {
points = {
{ "TOPRIGHT" },
},
},
bottomRight = {
points = {
{ "BOTTOMRIGHT" },
},
},
left = {
points = {
{ "TOPLEFT", "topLeft", "BOTTOMLEFT" },
{ "BOTTOMLEFT", "bottomLeft", "TOPLEFT" },
},
},
right = {
points = {
{ "TOPRIGHT", "topRight", "BOTTOMRIGHT" },
{ "BOTTOMRIGHT", "bottomRight", "TOPRIGHT" },
},
},
top = {
points = {
{ "TOPLEFT", "topLeft", "TOPRIGHT" },
{ "TOPRIGHT", "topRight", "TOPLEFT" },
},
},
bottom = {
points = {
{ "BOTTOMLEFT", "bottomLeft", "BOTTOMRIGHT" },
{ "BOTTOMRIGHT", "bottomRight", "BOTTOMLEFT" },
},
},
center = {
points = {
{ "TOPLEFT", "topLeft", "BOTTOMRIGHT" },
{ "BOTTOMRIGHT", "bottomRight", "TOPLEFT" },
},
},
}
-- ============================================================================
-- Metatable
-- ============================================================================
local NINE_SLICE_MT = {
__index = {
Hide = function(self)
local context = private.context[self]
for _, texture in pairs(context.parts) do
texture:Hide()
end
end,
SetStyle = function(self, key, inset)
local context = private.context[self]
local style = private.styles[key]
for part, texture in pairs(context.parts) do
local partStyle = style[part]
if partStyle then
texture:Show()
else
texture:Hide()
end
end
if context.styleKey == key and context.inset == inset then
return
end
context.styleKey = key
context.inset = inset
for part, texture in pairs(context.parts) do
local partStyle = style[part]
if partStyle then
texture:ClearAllPoints()
for i, point in ipairs(PART_INFO[part].points) do
local anchor, relFrame, relAnchor, xOff, yOff = nil, nil, nil, 0, 0
if partStyle.offset then
xOff, yOff = unpack(partStyle.offset[i])
end
if #point == 1 then
anchor = unpack(point)
elseif #point == 3 then
anchor, relFrame, relAnchor = unpack(point)
relFrame = context.parts[relFrame]
assert(relFrame)
else
error("Invalid point")
end
if relFrame then
texture:SetPoint(anchor, relFrame, relAnchor, xOff, yOff)
else
if inset and xOff == 0 and strmatch(anchor, "LEFT") then
xOff = inset
elseif inset and xOff == 0 and strmatch(anchor, "RIGHT") then
xOff = -inset
end
if inset and yOff == 0 and strmatch(anchor, "TOP") then
yOff = -inset
elseif inset and yOff == 0 and strmatch(anchor, "BOTTOM") then
yOff = inset
end
texture:SetPoint(anchor, xOff, yOff)
end
end
texture:SetSize(partStyle.width, partStyle.height)
texture:SetTexture(partStyle.texture)
texture:SetTexCoord(unpack(partStyle.coord))
end
end
end,
SetVertexColor = function(self, r, g, b, a)
for part in pairs(PART_INFO) do
self:SetPartVertexColor(part, r, g, b, a)
end
end,
SetPartVertexColor = function(self, part, r, g, b, a)
local context = private.context[self]
context.parts[part]:SetVertexColor(r, g, b, a)
end,
},
__newindex = function(self, key, value) error("NineSlice cannot be modified") end,
__tostring = function(self) return "NineSlice:"..strmatch(tostring(private.context[self]), "table:[^0-9a-fA-F]*([0-9a-fA-F]+)") end,
__metatable = false,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Registers a nine-slice style.
-- @tparam string key The style key
-- @tparam table info The style info table
function NineSlice.RegisterStyle(key, info)
assert(not private.styles[key])
private.styles[key] = info
for part in pairs(PART_INFO) do
-- allowed to be missing the center part
if part ~= "center" or info[part] ~= nil then
assert(type(info[part].texture) == "string")
assert(#info[part].coord == 4)
assert(info[part].width > 0)
assert(info[part].height > 0)
end
end
end
--- Create an nine-slice object.
-- @tparam table frame The parent frame
-- @tparam[opt=0] number subLayer The texture subLayer
-- @treturn NineSlice The nine-slice object
function NineSlice.New(frame, subLayer)
local obj = setmetatable({}, NINE_SLICE_MT)
local context = {
frame = frame,
parts = {},
styleKey = nil,
inset = nil,
}
private.context[obj] = context
-- create all the textures
for part in pairs(PART_INFO) do
local texture = frame:CreateTexture(nil, "BACKGROUND", nil, subLayer or 0)
texture:SetBlendMode("BLEND")
context.parts[part] = texture
end
-- set the points for all the textures
for part, info in pairs(PART_INFO) do
for _, point in ipairs(info.points) do
if #point == 1 then
context.parts[part]:SetPoint(unpack(point))
elseif #point == 3 then
local anchor, relFrame, relAnchor = unpack(point)
relFrame = context.parts[relFrame]
assert(relFrame)
context.parts[part]:SetPoint(anchor, relFrame, relAnchor)
else
error("Invalid point")
end
end
end
return obj
end

120
LibTSM/Util/ObjectPool.lua Normal file
View File

@@ -0,0 +1,120 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- ObjectPool Functions.
-- @module ObjectPool
local _, TSM = ...
local ObjectPool = TSM.Init("Util.ObjectPool")
local Debug = TSM.Include("Util.Debug")
local private = {
debugLeaks = TSM.__IS_TEST_ENV or false,
instances = {},
context = {},
}
local DEBUG_STATS_MIN_COUNT = 1
-- ============================================================================
-- Metatable
-- ============================================================================
local OBJECT_POOL_MT = {
__index = {
Get = function(self)
local context = private.context[self]
local obj = tremove(context.freeList)
if not obj then
context.numCreated = context.numCreated + 1
obj = context.createFunc()
assert(obj)
end
if private.debugLeaks then
context.state[obj] = (Debug.GetStackLevelLocation(2 + context.extraStackOffset) or "?").." -> "..(Debug.GetStackLevelLocation(3 + context.extraStackOffset) or "?")
else
context.state[obj] = "???"
end
return obj
end,
Recycle = function(self, obj)
local context = private.context[self]
assert(context.state[obj])
context.state[obj] = nil
tinsert(context.freeList, obj)
end,
},
__newindex = function(self, key, value) error("Object pool cannot be modified") end,
__metatable = false,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Create a new object pool.
-- @tparam string name The name of the object pool for debug purposes
-- @tparam function createFunc The function which is called to create a new object
-- @tparam[opt=0] number extraStackOffset The extra stack offset for tracking where objects are being used from or nil to disable stack info
-- @treturn ObjectPool The object pool object
function ObjectPool.New(name, createFunc, extraStackOffset)
assert(createFunc)
assert(not private.instances[name])
local pool = setmetatable({}, OBJECT_POOL_MT)
private.context[pool] = {
createFunc = createFunc,
extraStackOffset = extraStackOffset or 0,
freeList = {},
state = {},
numCreated = 0,
}
private.instances[name] = private.context[pool]
return pool
end
function ObjectPool.EnableLeakDebug()
private.debugLeaks = true
end
function ObjectPool.GetDebugInfo()
local debugInfo = {}
for name, context in pairs(private.instances) do
local numCreated, numInUse, info = private.GetDebugStats(context)
debugInfo[name] = {
numCreated = numCreated,
numInUse = numInUse,
info = info,
}
end
return debugInfo
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetDebugStats(context)
local counts = {}
local totalCount = 0
for _, caller in pairs(context.state) do
counts[caller] = (counts[caller] or 0) + 1
totalCount = totalCount + 1
end
local debugInfo = {}
for info, count in pairs(counts) do
if count > DEBUG_STATS_MIN_COUNT then
tinsert(debugInfo, format("[%d] %s", count, info))
end
end
if #debugInfo == 0 then
tinsert(debugInfo, "<none>")
end
return context.numCreated, totalCount, debugInfo
end

View File

@@ -0,0 +1,92 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ScriptWrapper = TSM.Init("Util.ScriptWrapper")
local Log = TSM.Include("Util.Log")
local private = {
handlers = {},
objLookup = {},
wrappers = {},
propagateWrappers = {},
nestedLevel = 0,
}
local SCRIPT_CALLBACK_TIME_WARNING_THRESHOLD_MS = 20
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Sets the handler for a script on a frame.
-- @tparam table frame The frame to call SetScript() on
-- @tparam string script The script to set
-- @tparam function handler The handler to set
-- @tparam[opt=frame] table obj The object to pass to the handler as the first parameter (instead of frame)
function ScriptWrapper.Set(frame, script, handler, obj)
assert(type(frame) == "table" and type(script) == "string" and type(handler) == "function")
local key = private.GetFrameScriptKey(frame, script)
private.handlers[key] = handler
private.objLookup[key] = obj or frame
if not private.wrappers[script] then
private.wrappers[script] = function(...)
private.ScriptHandlerCommon(script, ...)
end
end
frame:SetScript(script, private.wrappers[script])
end
--- Sets the script handler to simply propogate the script to the parent element.
-- @tparam table frame The frame to call SetScript() on
-- @tparam string script The script which should be propagated
-- @tparam[opt=frame] table obj The object to pass to the handler as the first parameter (instead of frame)
function ScriptWrapper.SetPropagate(frame, script, obj)
if not private.propagateWrappers[script] then
private.propagateWrappers[script] = function(f, ...)
local parentFrame = f:GetParent()
local parentScript = parentFrame:GetScript(script)
if not parentScript then
return
end
parentScript(parentFrame, ...)
end
end
ScriptWrapper.Set(frame, script, private.propagateWrappers[script], obj)
end
--- Clears a previously-registered script handler.
-- @tparam table frame The frame to clear the scrip ton
-- @tparam string script The script which should be cleared
function ScriptWrapper.Clear(frame, script)
local key = private.GetFrameScriptKey(frame, script)
private.handlers[key] = nil
private.objLookup[key] = nil
frame:SetScript(script, nil)
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetFrameScriptKey(frame, script)
return tostring(frame)..":"..script
end
function private.ScriptHandlerCommon(script, frame, ...)
local key = private.GetFrameScriptKey(frame, script)
local obj = private.objLookup[key]
private.nestedLevel = private.nestedLevel + 1
local startTime = debugprofilestop()
private.handlers[key](obj, ...)
local timeTaken = debugprofilestop() - startTime
private.nestedLevel = private.nestedLevel - 1
if private.nestedLevel == 0 and timeTaken > SCRIPT_CALLBACK_TIME_WARNING_THRESHOLD_MS then
Log.Warn("Script handler (%s) for frame (%s) took %0.2fms", script, tostring(obj), timeTaken)
end
end

36
LibTSM/Util/SlotId.lua Normal file
View File

@@ -0,0 +1,36 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- SlotId Functions
-- @module SlotId
local _, TSM = ...
local SlotId = TSM.Init("Util.SlotId")
local SLOT_ID_MULTIPLIER = 1000
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Combines a container and slot into a slotId.
-- @tparam number container The container
-- @tparam number slot The slot
-- @treturn number The slotId
function SlotId.Join(container, slot)
return container * SLOT_ID_MULTIPLIER + slot
end
--- Splits a slotId into a container and slot
-- @tparam number slotId The slotId
-- @treturn number container The container
-- @treturn number slot The slot
function SlotId.Split(slotId)
local container = floor(slotId / SLOT_ID_MULTIPLIER)
local slot = slotId % SLOT_ID_MULTIPLIER
return container, slot
end

217
LibTSM/Util/SmartMap.lua Normal file
View File

@@ -0,0 +1,217 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Smart Map.
-- @module SmartMap
local _, TSM = ...
local SmartMap = TSM.Init("Util.SmartMap")
local private = {
mapContext = {},
readerContext = {},
}
local VALID_FIELD_TYPES = {
string = true,
number = true,
boolean = true,
}
-- ============================================================================
-- Metatable Methods
-- ============================================================================
local SMART_MAP_MT = {
-- getter
__index = function(self, key)
if key == nil then
error("Attempt to get nil key")
end
if key == "ValueChanged" then
return private.MapValueChanged
elseif key == "SetCallbacksPaused" then
return private.MapSetCallbacksPaused
elseif key == "CreateReader" then
return private.MapCreateReader
elseif key == "GetKeyType" then
return private.MapGetKeyType
elseif key == "GetValueType" then
return private.MapGetValueType
elseif key == "Iterator" then
return private.MapIterator
else
error("Invalid map method: "..tostring(key), 2)
end
end,
-- setter
__newindex = function(self, key, value)
error("Map cannot be written to directly", 2)
end,
__tostring = function(self)
return "SmartMap:"..strmatch(tostring(private.mapContext[self]), "table:[^0-9a-fA-F]*([0-9a-fA-F]+)")
end,
__metatable = false,
}
local READER_MT = {
-- getter
__index = function(self, key)
-- check if the map already has the value for this key cached
local readerContext = private.readerContext[self]
local map = readerContext.map
local mapContext = private.mapContext[map]
if mapContext.data[key] ~= nil then
return mapContext.data[key]
end
-- get the value for this key
local value = mapContext.func(key)
if value == nil then
error(format("No value for key (%s)", tostring(key)))
elseif type(value) ~= mapContext.valueType then
error(format("Invalid type of value (got %s, expected %s): %s", type(value), mapContext.valueType, tostring(value)))
end
-- cache the value both on the map and on this reader
mapContext.data[key] = value
rawset(self, key, value)
return value
end,
-- setter
__newindex = function(self, key, value)
error("Reader is read-only", 2)
end,
__tostring = function(self)
return "SmartMapReader:"..strmatch(tostring(private.readerContext[self]), "table:[^0-9a-fA-F]*([0-9a-fA-F]+)")
end,
__metatable = false,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function SmartMap.New(keyType, valueType, callable)
assert(VALID_FIELD_TYPES[keyType] and VALID_FIELD_TYPES[valueType])
local map = setmetatable({}, SMART_MAP_MT)
private.mapContext[map] = {
keyType = keyType,
valueType = valueType,
func = callable,
data = {},
readers = {},
callbacksPaused = 0,
hasReaderCallback = false,
}
return map
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.MapValueChanged(self, key)
local mapContext = private.mapContext[self]
local oldValue = mapContext.data[key]
if oldValue == nil then
-- nobody cares about this value
return
end
if not mapContext.hasReaderCallback then
-- no reader has registered a callback, so just clear the value
mapContext.data[key] = nil
for _, reader in ipairs(mapContext.readers) do
rawset(reader, key, nil)
end
return
end
-- get the new value
local newValue = mapContext.func(key)
if type(newValue) ~= mapContext.valueType then
error(format("Invalid type (got %s, expected %s)", type(newValue), mapContext.valueType))
end
if oldValue == newValue then
-- the value didn't change
return
end
-- update the data
mapContext.data[key] = newValue
for _, reader in ipairs(mapContext.readers) do
local readerContext = private.readerContext[reader]
local prevValue = rawget(reader, key)
if prevValue ~= nil then
rawset(reader, key, newValue)
if readerContext.callback then
readerContext.pendingChanges[key] = prevValue
if mapContext.callbacksPaused == 0 then
readerContext.callback(reader, readerContext.pendingChanges)
wipe(readerContext.pendingChanges)
end
end
end
end
end
function private.MapSetCallbacksPaused(self, paused)
local mapContext = private.mapContext[self]
if paused then
mapContext.callbacksPaused = mapContext.callbacksPaused + 1
else
mapContext.callbacksPaused = mapContext.callbacksPaused - 1
assert(mapContext.callbacksPaused >= 0)
if mapContext.callbacksPaused == 0 then
for _, reader in ipairs(mapContext.readers) do
local readerContext = private.readerContext[reader]
if readerContext.callback and next(readerContext.pendingChanges) then
readerContext.callback(reader, readerContext.pendingChanges)
wipe(readerContext.pendingChanges)
end
end
end
end
end
function private.MapCreateReader(self, callback)
assert(callback == nil or type(callback) == "function")
local reader = setmetatable({}, READER_MT)
local mapContext = private.mapContext[self]
tinsert(mapContext.readers, reader)
mapContext.hasReaderCallback = mapContext.hasReaderCallback or (callback and true or false)
private.readerContext[reader] = {
map = self,
callback = callback,
pendingChanges = {},
}
return reader
end
function private.MapGetKeyType(self)
return private.mapContext[self].keyType
end
function private.MapGetValueType(self)
return private.mapContext[self].valueType
end
function private.MapIterator(self)
return pairs(private.mapContext[self].data)
end

84
LibTSM/Util/Sound.lua Normal file
View File

@@ -0,0 +1,84 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Sound Functions
-- @module Sound
local _, TSM = ...
local Sound = TSM.Init("Util.Sound")
local L = TSM.Include("Locale").GetTable()
local NO_SOUND_KEY = "TSM_NO_SOUND" -- this can never change
local SOUNDS = {
[NO_SOUND_KEY] = "<"..L["No Sound"]..">",
["AuctionWindowOpen"] = L["Auction Window Open"],
["AuctionWindowClose"] = L["Auction Window Close"],
["alarmclockwarning3"] = L["Alarm Clock"],
["UI_AutoQuestComplete"] = L["Auto Quest Complete"],
["TSM_CASH_REGISTER"] = L["Cash Register"],
["HumanExploration"] = L["Exploration"],
["Fishing Reel in"] = L["Fishing Reel In"],
["LevelUp"] = L["Level Up"],
["MapPing"] = L["Map Ping"],
["MONEYFRAMEOPEN"] = L["Money Frame Open"],
["IgPlayerInviteAccept"] = L["Player Invite Accept"],
["QUESTADDED"] = L["Quest Added"],
["QUESTCOMPLETED"] = L["Quest Completed"],
["UI_QuestObjectivesComplete"] = L["Quest Objectives Complete"],
["RaidWarning"] = L["Raid Warning"],
["ReadyCheck"] = L["Ready Check"],
["UnwrapGift"] = L["Unwrap Gift"],
}
local SOUNDKITIDS = {
["AuctionWindowOpen"] = 5274,
["AuctionWindowClose"] = 5275,
["alarmclockwarning3"] = 12889,
["UI_AutoQuestComplete"] = 23404,
["HumanExploration"] = 4140,
["Fishing Reel in"] = 3407,
["LevelUp"] = 888,
["MapPing"] = 3175,
["MONEYFRAMEOPEN"] = 891,
["IgPlayerInviteAccept"] = 880,
["QUESTADDED"] = 618,
["QUESTCOMPLETED"] = 878,
["UI_QuestObjectivesComplete"] = 26905,
["RaidWarning"] = 8959,
["ReadyCheck"] = 8960,
["UnwrapGift"] = 64329,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Gets the key used to represent no sound.
-- @return The key used to represent no sound
function Sound.GetNoSoundKey()
return NO_SOUND_KEY
end
--- Gets the key-value table containing all supported sounds.
-- The key is the what gets passed to @{Sound.PlaySound} and the value is a localized string describing the sound.
-- @return The sounds table
function Sound.GetSounds()
return SOUNDS
end
--- Plays a sound and flashes the client icon.
-- @param soundKey The key of the sound (from @{Sound.GetSounds}) to play
function Sound.PlaySound(soundKey)
if soundKey == NO_SOUND_KEY then
-- do nothing
elseif soundKey == "TSM_CASH_REGISTER" then
PlaySoundFile("Interface\\Addons\\TradeSkillMaster\\Media\\register.mp3", "Master")
FlashClientIcon()
else
PlaySound(SOUNDKITIDS[soundKey], "Master")
FlashClientIcon()
end
end

93
LibTSM/Util/String.lua Normal file
View File

@@ -0,0 +1,93 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- String Functions
-- @module String
local _, TSM = ...
local String = TSM.Init("Util.String")
local MAGIC_CHARACTERS = {
["["] = true,
["]"] = true,
["("] = true,
[")"] = true,
["."] = true,
["+"] = true,
["-"] = true,
["*"] = true,
["?"] = true,
["^"] = true,
["$"] = true,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Splits a string in a way which won't cause stack overflows for large inputs.
-- The lua strsplit function causes a stack overflow if passed large inputs. This API fixes that issue and also supports
-- separators which are more than one character in length.
-- @tparam string str The string to be split
-- @tparam string sep The separator to use to split the string
-- @tparam[opt=nil] table resultTbl An optional table to store the result in
-- @treturn table The result as a list of substrings
function String.SafeSplit(str, sep, resultTbl)
resultTbl = resultTbl or {}
local s = 1
local sepLength = #sep
if sepLength == 0 then
tinsert(resultTbl, str)
return resultTbl
end
while true do
local e = strfind(str, sep, s)
if not e then
tinsert(resultTbl, strsub(str, s))
break
end
tinsert(resultTbl, strsub(str, s, e - 1))
s = e + sepLength
end
return resultTbl
end
--- Escapes any magic characters used by lua's pattern matching.
-- @tparam string str The string to be escaped
-- @treturn string The escaped string
function String.Escape(str)
assert(not strmatch(str, "\001"), "Input string must not contain '\\001' characters")
str = gsub(str, "%%", "\001")
for char in pairs(MAGIC_CHARACTERS) do
str = gsub(str, "%"..char, "%%"..char)
end
str = gsub(str, "\001", "%%%%")
return str
end
--- Check if a string which contains multiple values separated by a specific string contains the value.
-- @tparam string str The string to be searched
-- @tparam string sep The separating string
-- @tparam string value The value to search for
-- @treturn boolean Whether or not the value was found
-- @within String
function String.SeparatedContains(str, sep, value)
return str == value or strmatch(str, "^"..value..sep) or strmatch(str, sep..value..sep) or strmatch(str, sep..value.."$")
end
--- Iterates over the parts of a string which are separated by a character.
-- @tparam string str The string to be split
-- @tparam string sep The separator to use to split the string
-- @return An iterator with fields: `part`
-- @within String
function String.SplitIterator(str, sep)
assert(#sep == 1)
if MAGIC_CHARACTERS[sep] then
sep = "%"..sep
end
return gmatch(str, "([^"..sep.."]+)")
end

484
LibTSM/Util/Table.lua Normal file
View File

@@ -0,0 +1,484 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Table Functions
-- @module Table
local _, TSM = ...
local Table = TSM.Init("Util.Table")
local TempTable = TSM.Include("Util.TempTable")
local private = {
filterTemp = {},
sortValueLookup = nil,
sortValueReverse = false,
sortValueUnstable = false,
iterContext = { arg = {}, index = {}, helperFunc = {}, cleanupFunc = {} },
inwardIteratorContext = {},
}
setmetatable(private.iterContext.arg, { __mode = "k" })
setmetatable(private.iterContext.index, { __mode = "k" })
setmetatable(private.iterContext.helperFunc, { __mode = "k" })
setmetatable(private.iterContext.cleanupFunc, { __mode = "k" })
local READ_ONLY_TABLE_MT = {
__index = function(_, key) error(format("Key (%s) does not exist in read-only table", tostring(key)), 2) end,
__newindex = function(_, key) error(format("Writing (%s) to read-only table", tostring(key)), 2) end,
__metatable = false,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Creates an iterator from a table.
-- NOTE: This iterator must be run to completion and not interrupted (i.e. with a `break` or `return`).
-- @tparam table tbl The table (numerically-indexed) to iterate over
-- @tparam[opt] function helperFunc A helper function which gets passed the current index, value, and user-specified arg
-- and returns nothing if an entry in the table should be skipped or the result of an iteration loop
-- @param[opt] arg A value to be passed to the helper function
-- @tparam[opt] function cleanupFunc A function to be called (passed `tbl`) to cleanup at the end of iterator
-- @return An iterator with fields: `index, value` or the return of `helperFunc`
function Table.Iterator(tbl, helperFunc, arg, cleanupFunc)
local iterContext = TempTable.Acquire()
iterContext.data = tbl
iterContext.arg = arg
iterContext.index = 0
iterContext.helperFunc = helperFunc
iterContext.cleanupFunc = cleanupFunc
return private.TableIterator, iterContext
end
--- Creates an iterator from the keys of a table.
-- @tparam table tbl The table to iterate over the keys of
-- @return An iterator with fields: `key`
function Table.KeyIterator(tbl)
return private.TableKeyIterator, tbl, nil
end
--- Uses a function to filter the entries in a table.
-- @tparam table tbl The table to be filtered
-- @tparam function func The filter function which gets passed `key, value, ...` and returns true if that entry should
-- be removed from the table
-- @param[opt] ... Optional arguments to be passed to the filter function
function Table.Filter(tbl, func, ...)
assert(not next(private.filterTemp))
for k, v in pairs(tbl) do
if func(k, v, ...) then
tinsert(private.filterTemp, k)
end
end
for _, k in ipairs(private.filterTemp) do
tbl[k] = nil
end
wipe(private.filterTemp)
end
--- Removes all occurences of the value in the table.
-- Only the numerically-indexed entries are checked.
-- @tparam table tbl The table to remove the value from
-- @param value The value to remove
-- @treturn number The number of values removed
function Table.RemoveByValue(tbl, value)
local numRemoved = 0
for i = #tbl, 1, -1 do
if tbl[i] == value then
tremove(tbl, i)
numRemoved = numRemoved + 1
end
end
return numRemoved
end
--- Gets the table key by value.
-- @tparam table tbl The table to look through
-- @param value The value to get the key of
-- @return The key for the specified value or `nil`
function Table.KeyByValue(tbl, value)
for k, v in pairs(tbl) do
if v == value then
return k
end
end
end
--- Gets the number of entries in the table.
-- This can be used when the count of a non-numerically-indexed table is desired (i.e. `#tbl` wouldn't work).
-- @tparam table tbl The table to get the number of entries in
-- @treturn number The number of entries
function Table.Count(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
--- Gets the distinct table key by value.
-- This function will assert if the value is not found in the table or if more than one key is found.
-- @tparam table tbl The table to look through
-- @param value The value to get the key of
-- @return The key for the specified value
function Table.GetDistinctKey(tbl, value)
local key = nil
for k, v in pairs(tbl) do
if v == value then
assert(not key)
key = k
end
end
assert(key)
return key
end
--- Checks if two tables have the same entries (non-recursively).
-- @tparam table tbl1 The first table to check
-- @tparam table tbl2 The second table to check
-- @treturn boolean Whether or not the tables are equal
function Table.Equal(tbl1, tbl2)
if Table.Count(tbl1) ~= Table.Count(tbl2) then
return false
end
for k, v in pairs(tbl1) do
if tbl2[k] ~= v then
return false
end
end
return true
end
--- Returns whether or not the table is currently sorted.
-- @tparam table tbl The table to check
-- @tparam[opt] function sortFunc The helper function to use to determine sort order (same prototype as is used for `sort()`)
-- @tparam[opt=1] number firstIndex The first index to check
-- @tparam[opt=#tbl] number lastIndex The last index to check
-- @treturn boolean Whether or not the table is sorted
function Table.IsSorted(tbl, sortFunc, firstIndex, lastIndex)
sortFunc = sortFunc or private.DefaultSortFunc
firstIndex = firstIndex or 1
lastIndex = lastIndex or #tbl
local prevValue = tbl[firstIndex]
for i = firstIndex + 1, lastIndex do
local value = tbl[i]
if sortFunc(value, prevValue) then
return false
end
prevValue = value
end
return true
end
--- Sorts a table with some optimizations over lua's sort().
-- @tparam table tbl The table to sort
-- @tparam[opt] function sortFunc The helper function to use to determine sort order (same prototype as is used for `sort()`)
function Table.Sort(tbl, sortFunc)
if Table.IsSorted(tbl, sortFunc) then
return
end
sort(tbl, sortFunc)
end
--- Returns whether or not the table is currently sorted with a value lookup table.
-- @tparam table tbl The table to sort
-- @tparam table valueLookup The sort value lookup table
-- @tparam[opt=1] number firstIndex The first index to check
-- @tparam[opt=#tbl] number lastIndex The last index to check
function Table.IsSortedWithValueLookup(tbl, valueLookup, firstIndex, lastIndex)
assert(not private.sortValueLookup and valueLookup)
private.sortValueLookup = valueLookup
private.sortValueReverse = false
private.sortValueUnstable = false
local result = Table.IsSorted(tbl, private.TableSortWithValueLookupHelper, firstIndex, lastIndex)
private.sortValueLookup = nil
return result
end
--- Merges two sorted tables with a value lookup table.
-- @tparam table tbl1 The first table to merge
-- @tparam table tbl2 The second table to merge
-- @tparam table result The result table
-- @tparam table valueLookup The sort value lookup table
function Table.MergeSortedWithValueLookup(tbl1, tbl2, result, valueLookup)
assert(not private.sortValueLookup and valueLookup)
private.sortValueLookup = valueLookup
private.sortValueReverse = false
private.sortValueUnstable = false
local index1, index2, resultIndex = 1, 1, 1
while true do
local value1 = tbl1[index1]
local value2 = tbl2[index2]
if value1 == nil and value2 == nil then
-- we're done
break
elseif value1 == nil then
result[resultIndex] = value2
index2 = index2 + 1
elseif value2 == nil then
result[resultIndex] = value1
index1 = index1 + 1
elseif private.TableSortWithValueLookupHelper(value1, value2) then
result[resultIndex] = value1
index1 = index1 + 1
else
result[resultIndex] = value2
index2 = index2 + 1
end
resultIndex = resultIndex + 1
end
private.sortValueLookup = nil
end
--- Does a table sort with an extra value lookup step.
-- @tparam table tbl The table to sort
-- @tparam table valueLookup The sort value lookup table
-- @tparam[opt=false] boolean reverse Reverse the sort order
-- @tparam[opt=false] boolean unstable Don't try to make the sort stable
function Table.SortWithValueLookup(tbl, valueLookup, reverse, unstable)
assert(not private.sortValueLookup and valueLookup)
private.sortValueLookup = valueLookup
private.sortValueReverse = reverse
private.sortValueUnstable = unstable
Table.Sort(tbl, private.TableSortWithValueLookupHelper)
private.sortValueLookup = nil
end
--- Creates an iterator which iterates through a numerically-indexed table (list) from the ends inward.
-- @tparam table tbl The table to iterate over
-- @return An iterator with fields: `index`, `value`, `isAscending`
function Table.InwardIterator(tbl)
assert(not private.inwardIteratorContext[tbl])
local context = TempTable.Acquire()
private.inwardIteratorContext[tbl] = context
context.inUse = true
context.tbl = tbl
context.leftIndex = 1
context.rightIndex = #tbl
context.isAscending = true
return private.InwardIteratorHelper, context, 0
end
--- Reverses the direction of the current inward iterator.
-- @tparam table tbl The table being iterated over
function Table.InwardIteratorReverse(tbl)
local context = private.inwardIteratorContext[tbl]
assert(context and context.tbl == tbl)
context.isAscending = not context.isAscending
end
--- Sets a table as read-only (modifications aren't checked).
-- @tparam table tbl The table to make read-only
function Table.SetReadOnly(tbl)
setmetatable(tbl, READ_ONLY_TABLE_MT)
end
--- Appends all values passed in to the end of the table.
-- @tparam table tbl The table to insert the data into
-- @param ... The values to insert
function Table.Append(tbl, ...)
local len = #tbl
for i = 1, select("#", ...) do
tbl[len + i] = select(i, ...)
end
end
--- Performs a binary search on a sorted table and returns the index of the search value.
-- @tparam table tbl The table to search
-- @tparam number|string searchValue The value to search for
-- @tparam[opt=nil] function valueFunc A function to call to get the value to compare
-- @param ... Extra values to pass to valueFunc
-- @treturn ?number The index of the value or nil if it wasn't found
-- @treturn ?number The insert index
function Table.BinarySearch(tbl, searchValue, valueFunc, ...)
if valueFunc then
searchValue = valueFunc(searchValue, ...)
end
local insertIndex = 1
local low, mid, high = 1, 0, #tbl
while low <= high do
mid = floor((low + high) / 2)
local value = tbl[mid]
if valueFunc then
value = valueFunc(tbl[mid], ...)
end
if value == searchValue then
return mid, mid
elseif value < searchValue then
-- we're too low
low = mid + 1
else
-- we're too high
high = mid - 1
end
insertIndex = low
end
return nil, insertIndex
end
--- Inserts a value into a sorted table by using the insertIndex returned by Table.BinarySearch().
-- @see Table.BinarySearch
-- @tparam table tbl The table
-- @tparam number|string value The value to insert
-- @tparam[opt=nil] function valueFunc A function to call to get the value to compare
-- @param ... Extra values to pass to valueFunc
function Table.InsertSorted(tbl, value, valueFunc, ...)
local _, insertIndex = Table.BinarySearch(tbl, value, valueFunc, ...)
tinsert(tbl, insertIndex, value)
end
--- Gets the common values from two or more sorted tables.
-- @tparam table tbls The tables to compare
-- @tparam table result The result table
-- @tparam[opt=nil] function valueFunc A function to call to get the value to compare
-- @param ... Extra values to pass to valueFunc
function Table.GetCommonValuesSorted(tbls, result, valueFunc, ...)
local numTbls = #tbls
if numTbls == 0 then
return
elseif numTbls == 1 then
for i = 1, #tbls[1] do
result[i] = tbls[1][i]
end
return
end
-- initialize our iterator indexes
for i = 1, numTbls do
local t = tbls[i]
assert(t._index == nil)
t._index = 1
end
while true do
-- go through each list and check if the current values are equal and get the max value
local isDone, isEqual = false, true
local equalValue, maxValue = nil, nil
for i = 1, numTbls do
local t = tbls[i]
local value = t[t._index]
value = value and valueFunc and valueFunc(value, ...)
if not value then
isDone = true
break
elseif i == 1 then
equalValue = value
maxValue = value
else
if value ~= equalValue then
isEqual = false
end
if value > maxValue then
maxValue = value
end
end
end
if isDone then
break
end
if isEqual then
-- all lists contained the same value, so insert it into our result and advance all the indexes
tinsert(result, tbls[1][tbls[1]._index])
for i = 1, numTbls do
local t = tbls[i]
t._index = t._index + 1
end
else
-- all lists aren't on the same value, so advanced each one to at least the current max value
for i = 1, numTbls do
local t = tbls[i]
local value = t[t._index]
value = value and valueFunc and valueFunc(value, ...)
while value and value < maxValue do
t._index = t._index + 1
value = t[t._index]
value = value and valueFunc and valueFunc(value, ...)
end
end
if isDone then
break
end
end
end
-- clear all our iterator indexes
for i = 1, numTbls do
tbls[i]._index = nil
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.TableKeyIterator(tbl, prevKey)
local key = next(tbl, prevKey)
return key
end
function private.TableIterator(iterContext)
iterContext.index = iterContext.index + 1
if iterContext.index > #iterContext.data then
local data = iterContext.data
local cleanupFunc = iterContext.cleanupFunc
TempTable.Release(iterContext)
if cleanupFunc then
cleanupFunc(data)
end
return
end
if iterContext.helperFunc then
local result = TempTable.Acquire(iterContext.helperFunc(iterContext.index, iterContext.data[iterContext.index], iterContext.arg))
if #result == 0 then
TempTable.Release(result)
return private.TableIterator(iterContext)
end
return TempTable.UnpackAndRelease(result)
else
return iterContext.index, iterContext.data[iterContext.index]
end
end
function private.TableSortWithValueLookupHelper(a, b)
local aValue = private.sortValueLookup[a]
local bValue = private.sortValueLookup[b]
if aValue == bValue then
if private.sortValueUnstable then
return false
else
return a > b
end
end
if private.sortValueReverse then
return aValue > bValue
else
return aValue < bValue
end
end
function private.InwardIteratorHelper(context)
if context.leftIndex > context.rightIndex then
private.inwardIteratorContext[context.tbl] = nil
TempTable.Release(context)
return
end
local index = nil
if context.isAscending then
-- iterating in ascending order
index = context.leftIndex
context.leftIndex = context.leftIndex + 1
else
-- iterating in descending order
index = context.rightIndex
context.rightIndex = context.rightIndex - 1
end
return index, context.tbl[index], context.isAscending
end
function private.DefaultSortFunc(a, b)
return a < b
end

141
LibTSM/Util/TempTable.lua Normal file
View File

@@ -0,0 +1,141 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- TempTable Functions
-- @module TempTable
local _, TSM = ...
local TempTable = TSM.Init("Util.TempTable")
local Debug = TSM.Include("Util.Debug")
local private = {
debugLeaks = TSM.__IS_TEST_ENV or false,
freeTempTables = {},
tempTableState = {},
}
local NUM_TEMP_TABLES = 100
local RELEASED_TEMP_TABLE_MT = {
__newindex = function(self, key, value)
error("Attempt to access temp table after release")
end,
__index = function(self, key)
error("Attempt to access temp table after release")
end,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Acquires a temporary table.
-- Temporary tables are recycled tables which can be used instead of creating a new table every time one is needed for a
-- defined lifecycle. This avoids relying on the garbage collector and improves overall performance.
-- @param ... Any number of valuse to insert into the table initially
-- @treturn table The temporary table
function TempTable.Acquire(...)
local tbl = tremove(private.freeTempTables, 1)
assert(tbl, "Could not acquire temp table")
setmetatable(tbl, nil)
if private.debugLeaks then
private.tempTableState[tbl] = (Debug.GetStackLevelLocation(2) or "?").." -> "..(Debug.GetStackLevelLocation(3) or "?")
else
private.tempTableState[tbl] = true
end
for i = 1, select("#", ...) do
tbl[i] = select(i, ...)
end
return tbl
end
--- Iterators over a temporary table, releasing it when done.
-- NOTE: This iterator must be run to completion and not interrupted (i.e. with a `break` or `return`).
-- @tparam table tbl The temporary table to iterator over
-- @tparam[opt=1] number numFields The number of fields to unpack with each iteration
-- @return An iterator with fields: `index, {numFields...}`
function TempTable.Iterator(tbl, numFields)
numFields = numFields or 1
assert(numFields >= 1 and #tbl % numFields == 0)
assert(private.tempTableState[tbl])
tbl.__iterNumFields = numFields
return private.TempTableIteratorHelper, tbl, 1 - numFields
end
--- Releases a temporary table.
-- The temporary table will be returned to the pool and must not be accessed after being released.
-- @tparam table tbl The temporary table to release
function TempTable.Release(tbl)
private.TempTableReleaseHelper(tbl)
end
--- Releases a temporary table and returns its values.
-- Releases the temporary table (see @{TempTable.Release}) and returns its unpacked values.
-- @tparam table tbl The temporary table to release and unpack
-- @return The result of calling `unpack` on the table
function TempTable.UnpackAndRelease(tbl)
return private.TempTableReleaseHelper(tbl, unpack(tbl))
end
function TempTable.EnableLeakDebug()
private.debugLeaks = true
end
function TempTable.GetDebugInfo()
local debugInfo = {}
local counts = {}
for _, info in pairs(private.tempTableState) do
counts[info] = (counts[info] or 0) + 1
end
for info, count in pairs(counts) do
tinsert(debugInfo, format("[%d] %s", count, type(info) == "string" and info or "?"))
end
if #debugInfo == 0 then
tinsert(debugInfo, "<none>")
end
return debugInfo
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.TempTableIteratorHelper(tbl, index)
local numFields = tbl.__iterNumFields
index = index + numFields
if index > #tbl then
TempTable.Release(tbl)
return
end
if numFields == 1 then
return index, tbl[index]
else
return index, unpack(tbl, index, index + numFields - 1)
end
end
function private.TempTableReleaseHelper(tbl, ...)
assert(private.tempTableState[tbl])
wipe(tbl)
tinsert(private.freeTempTables, tbl)
private.tempTableState[tbl] = nil
setmetatable(tbl, RELEASED_TEMP_TABLE_MT)
return ...
end
-- ============================================================================
-- Temp Table Setup
-- ============================================================================
do
for _ = 1, NUM_TEMP_TABLES do
local tempTbl = setmetatable({}, RELEASED_TEMP_TABLE_MT)
tinsert(private.freeTempTables, tempTbl)
end
end

318
LibTSM/Util/Theme.lua Normal file
View File

@@ -0,0 +1,318 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Theme Functions.
-- @module Theme
local _, TSM = ...
local Theme = TSM.Init("Util.Theme")
local FontPaths = TSM.Include("Data.FontPaths")
local Table = TSM.Include("Util.Table")
local Color = TSM.Include("Util.Color")
local FontObject = TSM.Include("Util.FontObject")
local private = {
callbacks = {},
names = {},
colorSets = {},
currentColorSet = nil,
fontFrame = nil,
currentFontSet = nil,
}
local THEME_COLOR_KEYS = {
FRAME_BG = true,
PRIMARY_BG = true,
PRIMARY_BG_ALT = true,
ACTIVE_BG = true,
ACTIVE_BG_ALT = true,
}
local STATIC_COLORS = {
INDICATOR = Color.NewFromHex("#ffd839"),
INDICATOR_ALT = Color.NewFromHex("#79a2ff"),
INDICATOR_DISABLED = Color.NewFromHex("#6f5819"),
TEXT = Color.NewFromHex("#ffffff"),
TEXT_ALT = Color.NewFromHex("#e2e2e2"),
TEXT_DISABLED = Color.NewFromHex("#424242"),
}
local FEEDBACK_COLORS = {
RED = Color.NewFromHex("#f72d20"),
YELLOW = Color.NewFromHex("#e1f720"),
GREEN = Color.NewFromHex("#4ff720"),
BLUE = Color.NewFromHex("#2076f7"),
ORANGE = Color.NewFromHex("#f77a20"),
}
local BLIZZARD_COLOR = Color.NewFromHex("#00b4ff")
local GROUP_COLORS = {
Color.NewFromHex("#fcf141"),
Color.NewFromHex("#bdaec6"),
Color.NewFromHex("#06a2cb"),
Color.NewFromHex("#ffb85c"),
Color.NewFromHex("#51b599"),
}
local PROFESSION_DIFFICULTY_COLORS = {
optimal = Color.NewFromHex("#ff8040"),
medium = Color.NewFromHex("#ffff00"),
easy = Color.NewFromHex("#40c040"),
trivial = Color.NewFromHex("#808080"),
header = Color.NewFromHex("#ffd100"),
subheader = Color.NewFromHex("#ffd100"),
nodifficulty = Color.NewFromHex("#f5f5f5"),
}
-- NOTE: there is a global ITEM_QUALITY_COLORS so we need to use another name
local TSM_ITEM_QUALITY_COLORS = {
[0] = Color.NewFromHex("#9d9d9d"),
[1] = Color.NewFromHex("#ffffff"),
[2] = Color.NewFromHex("#1eff00"),
[3] = Color.NewFromHex("#0070dd"),
[4] = Color.NewFromHex("#a334ee"),
[5] = Color.NewFromHex("#ff8000"),
[6] = Color.NewFromHex("#e6cc80"),
[7] = Color.NewFromHex("#00ccff"),
[8] = Color.NewFromHex("#00ccff"),
}
local AUCTION_PCT_COLORS = {
{ -- blue
color = "BLUE",
value = 50,
},
{ -- green
color = "GREEN",
value = 80,
},
{ -- yellow
color = "YELLOW",
value = 110,
},
{ -- orange
color = "ORANGE",
value = 135,
},
{ -- red
color = "RED",
value = math.huge,
},
default = "TEXT",
bid = "TEXT_ALT",
}
local CONSTANTS = {
COL_SPACING = 8,
SCROLLBAR_MARGIN = 4,
SCROLLBAR_WIDTH = 4,
MOUSE_WHEEL_SCROLL_AMOUNT = 60,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
Theme:OnModuleLoad(function()
Table.SetReadOnly(STATIC_COLORS)
Table.SetReadOnly(FEEDBACK_COLORS)
Table.SetReadOnly(GROUP_COLORS)
Table.SetReadOnly(PROFESSION_DIFFICULTY_COLORS)
Table.SetReadOnly(TSM_ITEM_QUALITY_COLORS)
Table.SetReadOnly(CONSTANTS)
-- create a frame to load fonts
private.fontFrame = CreateFrame("Frame", nil, UIParent)
private.fontFrame.texts = {}
private.fontFrame:SetAllPoints()
private.fontFrame:SetScript("OnUpdate", private.FontFrameOnUpdate)
-- TODO: eventually allow for different font sets?
private.currentFontSet = {
HEADING_H5 = FontObject.New(FontPaths.GetBodyRegular(), 20, 28),
BODY_BODY1 = FontObject.New(FontPaths.GetBodyRegular(), 16, 24),
BODY_BODY1_BOLD = FontObject.New(FontPaths.GetBodyBold(), 16, 24),
BODY_BODY2 = FontObject.New(FontPaths.GetBodyRegular(), 14, 20),
BODY_BODY2_MEDIUM = FontObject.New(FontPaths.GetBodyMedium(), 14, 20),
BODY_BODY2_BOLD = FontObject.New(FontPaths.GetBodyBold(), 14, 20),
BODY_BODY3 = FontObject.New(FontPaths.GetBodyRegular(), 12, 20),
BODY_BODY3_MEDIUM = FontObject.New(FontPaths.GetBodyMedium(), 12, 20),
ITEM_BODY1 = FontObject.New(FontPaths.GetItem(), 16, 24),
ITEM_BODY2 = FontObject.New(FontPaths.GetItem(), 14, 20),
ITEM_BODY3 = FontObject.New(FontPaths.GetItem(), 12, 20),
TABLE_TABLE1 = FontObject.New(FontPaths.GetTable(), 12, 20),
}
-- load the fonts
for _, obj in pairs(private.currentFontSet) do
local fontPath = obj:GetWowFont()
private.QueueFontLoad(fontPath)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Registers a callback when the theme changes.
-- @tparam function callback The callback function
function Theme.RegisterChangeCallback(callback)
assert(type(callback) == "function")
tinsert(private.callbacks, callback)
end
--- Registers a new color set.
-- @tparam string key The key which represents the color set
-- @tparam string name The name of the color set
-- @tparam table colorSet The colors which make up the color set (with keys specified in `THEME_COLOR_KEYS`)
function Theme.RegisterColorSet(key, name, colorSet)
assert(not private.colorSets[key])
for k in pairs(THEME_COLOR_KEYS) do
assert(Color.IsInstance(colorSet[k]))
end
private.names[key] = name
private.colorSets[key] = colorSet
end
--- Sets the active color set.
-- @tparam string key The key which represents the color set
function Theme.SetActiveColorSet(key)
assert(private.colorSets[key])
if private.currentColorSet == private.colorSets[key] then
return
end
private.currentColorSet = private.colorSets[key]
for _, callback in ipairs(private.callbacks) do
callback()
end
end
function Theme.GetThemeName(key)
return private.names[key]
end
--- Gets the color object from the current active color set.
-- @tparam string key The key of the color to get
-- @treturn Color The color object
function Theme.GetColor(key, themeKey)
local colorKey, tintPct, opacityPct = strmatch(key, "^([A-Z_]+)([%-%+]?[0-9A-Z_]*)%%?([0-9A-Z_]*)$")
tintPct = tonumber(tintPct) or (tintPct ~= "" and tintPct or nil)
opacityPct = tonumber(opacityPct) or (opacityPct ~= "" and opacityPct or nil)
assert(colorKey)
local color = nil
if THEME_COLOR_KEYS[colorKey] then
color = themeKey and private.colorSets[themeKey][colorKey] or private.currentColorSet[colorKey]
else
color = STATIC_COLORS[colorKey]
end
assert(color)
if tintPct then
color = color:GetTint(tintPct)
end
if opacityPct then
color = color:GetOpacity(opacityPct)
end
return color
end
--- Gets the color object for a given feedback color key.
-- @tparam string key The key of the feedback color to get
-- @treturn Color The color object
function Theme.GetFeedbackColor(key)
return FEEDBACK_COLORS[key]
end
--- Gets the color object for Blizzard GMs.
-- @treturn Color The color object
function Theme.GetBlizzardColor()
return BLIZZARD_COLOR
end
--- Gets the color object for a given group level.
-- @tparam number level The level of the group (1-based)
-- @treturn Color The color object
function Theme.GetGroupColor(level)
level = ((level - 1) % #GROUP_COLORS) + 1
return GROUP_COLORS[level]
end
function Theme.GetProfessionDifficultyColor(difficulty)
return PROFESSION_DIFFICULTY_COLORS[difficulty]
end
function Theme.GetItemQualityColor(quality)
return TSM_ITEM_QUALITY_COLORS[quality]
end
function Theme.GetAuctionPercentColor(pct)
if pct == "BID" then
return Theme.GetColor(AUCTION_PCT_COLORS.bid)
end
for _, info in ipairs(AUCTION_PCT_COLORS) do
if pct < info.value then
return Theme.GetFeedbackColor(info.color)
end
end
return Theme.GetColor(AUCTION_PCT_COLORS.default)
end
--- Gets the font object from the current active font set.
-- @tparam string key The key of the font to get
-- @treturn FontObject The font object
function Theme.GetFont(key)
local fontObj = private.currentFontSet[key]
assert(fontObj)
return fontObj
end
--- Gets the column spacing constant value.
-- @treturn number The column spacing
function Theme.GetColSpacing()
return CONSTANTS.COL_SPACING
end
--- Gets the scrollbar margin constant value.
-- @treturn number The scrollbar margin
function Theme.GetScrollbarMargin()
return CONSTANTS.SCROLLBAR_MARGIN
end
--- Gets the scrollbar width constant value.
-- @treturn number The scrollbar width
function Theme.GetScrollbarWidth()
return CONSTANTS.SCROLLBAR_WIDTH
end
--- Gets the scrollbar width constant value.
-- @treturn number The scrollbar width
function Theme.GetMouseWheelScrollAmount()
return CONSTANTS.MOUSE_WHEEL_SCROLL_AMOUNT
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.QueueFontLoad(path)
if private.fontFrame.texts[path] then
return
end
local fontString = private.fontFrame:CreateFontString()
fontString:SetPoint("CENTER")
fontString:SetWidth(10000)
fontString:SetHeight(6)
fontString:SetFont(path, 6)
fontString:SetText("1")
private.fontFrame.texts[path] = fontString
private.fontFrame:Show()
end
function private.FontFrameOnUpdate(frame)
for _, fontString in pairs(frame.texts) do
if fontString:IsVisible() then
assert(fontString:GetStringWidth() > 0, "Text not loaded: "..tostring(fontString:GetFont()))
fontString:Hide()
end
end
frame:Hide()
end

48
LibTSM/Util/Vararg.lua Normal file
View File

@@ -0,0 +1,48 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Vararg Functions
-- @module Vararg
local _, TSM = ...
local Vararg = TSM.Init("Util.Vararg")
local TempTable = TSM.Include("Util.TempTable")
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Stores a varag into a table.
-- @tparam table tbl The table to store the values in
-- @param ... Zero or more values to store in the table
function Vararg.IntoTable(tbl, ...)
for i = 1, select("#", ...) do
tbl[i] = select(i, ...)
end
end
--- Creates an iterator from a vararg.
-- NOTE: This iterator must be run to completion and not interrupted (i.e. with a `break` or `return`).
-- @param ... The values to iterate over
-- @return An iterator with fields: `index, value`
function Vararg.Iterator(...)
return TempTable.Iterator(TempTable.Acquire(...))
end
--- Returns whether not the value exists within the vararg.
-- @param value The value to search for
-- @param ... Any number of values to search in
-- @treturn boolean Whether or not the value was found in the vararg
function Vararg.In(value, ...)
for i = 1, select("#", ...) do
if value == select(i, ...) then
return true
end
end
return false
end

67
LibTSM/Util/Wow.lua Normal file
View File

@@ -0,0 +1,67 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Wow Functions
-- @module Wow
local _, TSM = ...
local Wow = TSM.Init("Util.Wow")
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Shows a basic Wow message popup.
-- @tparam string text The text to display
function Wow.ShowBasicMessage(text)
if BasicMessageDialog:IsShown() then
return
end
BasicMessageDialog.Text:SetText(text)
BasicMessageDialog:Show()
end
--- Shows a WoW static popup dialog.
-- @tparam string name The unique (global) name of the dialog to be shown
function Wow.ShowStaticPopupDialog(name)
StaticPopupDialogs[name].preferredIndex = 4
StaticPopup_Show(name)
for i = 1, 100 do
if _G["StaticPopup" .. i] and _G["StaticPopup" .. i].which == name then
_G["StaticPopup" .. i]:SetFrameStrata("TOOLTIP")
break
end
end
end
--- Sets the WoW item ref frame to the specified link.
-- @tparam string link The itemLink to show the item ref frame for
function Wow.SafeItemRef(link)
if type(link) ~= "string" then return end
-- extract the Blizzard itemString for both items and pets
local blizzItemString = strmatch(link, "^\124c[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]\124H(item:[^\124]+)\124.+$")
blizzItemString = blizzItemString or strmatch(link, "^\124c[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]\124H(battlepet:[^\124]+)\124.+$")
if blizzItemString then
SetItemRef(blizzItemString, link, "LeftButton")
end
end
--- Checks if an addon is installed.
-- This function only checks if the addon is installed, not if it's enabled.
-- @tparam string name The name of the addon
-- @treturn boolean Whether or not the addon is installed
function Wow.IsAddonInstalled(name)
return select(2, GetAddOnInfo(name)) and true or false
end
--- Checks if an addon is currently enabled.
-- @tparam string name The name of the addon
-- @treturn boolean Whether or not the addon is enabled
function Wow.IsAddonEnabled(name)
return GetAddOnEnableState(UnitName("player"), name) == 2 and select(4, GetAddOnInfo(name)) and true or false
end