-- ------------------------------------------------------------------------------ -- -- TradeSkillMaster -- -- https://tradeskillmaster.com -- -- All Rights Reserved - Detailed license information included with addon. -- -- ------------------------------------------------------------------------------ -- --- Custom Price Functions -- @module CustomPrice local _, TSM = ... local CustomPrice = TSM.Init("Service.CustomPrice") local L = TSM.Include("Locale").GetTable() local DisenchantInfo = TSM.Include("Data.DisenchantInfo") local TempTable = TSM.Include("Util.TempTable") local Table = TSM.Include("Util.Table") local Math = TSM.Include("Util.Math") local Money = TSM.Include("Util.Money") local String = TSM.Include("Util.String") local Log = TSM.Include("Util.Log") local Theme = TSM.Include("Util.Theme") local ItemString = TSM.Include("Util.ItemString") local SmartMap = TSM.Include("Util.SmartMap") local ItemInfo = TSM.Include("Service.ItemInfo") local Settings = TSM.Include("Service.Settings") local Conversions = TSM.Include("Service.Conversions") local private = { context = {}, priceSourceKeys = {}, priceSourceInfo = {}, customPriceCache = {}, proxyData = {}, settings = nil, sanitizeCache = {}, customSourceCallbacks = {}, } local ITEM_STRING_PATTERN = "[ip]:[0-9:%-]+" local MONEY_PATTERNS = { "[^%.]([0-9]+g[ ]*[0-9]+s[ ]*[0-9]+c)", -- g/s/c "[^%.]([0-9]+g[ ]*[0-9]+s)", -- g/s "[^%.]([0-9]+g[ ]*[0-9]+c)", -- g/c "[^%.]([0-9]+s[ ]*[0-9]+c)", -- s/c "[^%.]([0-9]+g)", -- g "[^%.]([0-9]+s)", -- s "[^%.]([0-9]+c)", -- c } local MATH_FUNCTIONS = { ["avg"] = "self._avg", ["min"] = "self._min", ["max"] = "self._max", ["first"] = "self._first", ["check"] = "self._check", ["ifgt"] = "self._ifgt", ["ifgte"] = "self._ifgte", ["iflt"] = "self._iflt", ["iflte"] = "self._iflte", ["ifeq"] = "self._ifeq", ["round"] = "self._round", ["roundup"] = "self._roundup", ["rounddown"] = "self._rounddown", } local CUSTOM_PRICE_FUNC_TEMPLATE = [[ return function(self, _item, _baseitem) local isTop local context = self.globalContext if not context.num then context.num = 0 isTop = true end context.num = context.num + 1 if context.num > 100 then if (context.lastPrint or 0) + 1 < time() then context.lastPrint = time() self.loopError(self.origStr) end return end local result = floor((%s) + 0.5) if context.num then context.num = context.num - 1 end if isTop then context.num = nil end if not result or self.IsInvalid(result) or result <= 0 then return end return result end ]] local function IsInvalid(num) -- We want to treat math.huge/-math.huge/NAN as invalid. return num == math.huge or num == -math.huge or Math.IsNan(num) end -- Make sure our IsInvalid function continues to work as expected assert(IsInvalid(Math.GetNan()) and IsInvalid(math.huge) and IsInvalid(math.huge) and not IsInvalid(0) and not IsInvalid(1000)) local COMPARISONS = { ["gt"] = 1, ["gte"] = 2, ["lt"] = 3, ["lte"] = 4, ["eq"] = 5, } -- ============================================================================ -- Module Loading -- ============================================================================ CustomPrice:OnSettingsLoad(function() private.settings = Settings.NewView() :AddKey("global", "userData", "customPriceSources") for name, str in pairs(private.settings.customPriceSources) do if CustomPrice.ValidateName(name, true) then str = private.SanitizeCustomPriceString(str) private.settings.customPriceSources[name] = str for _, data in pairs(private.proxyData) do if data.origStr == str then data.customPriceSourceNames[name] = true end end else Log.PrintfUser(L["Removed custom price source (%s) which has an invalid name."], name) CustomPrice.DeleteCustomPriceSource(name) end end end) -- ============================================================================ -- Module Functions -- ============================================================================ function CustomPrice.OnEnable() for name in pairs(TSM.db.global.userData.customPriceSources) do if not CustomPrice.ValidateName(name, true) then Log.PrintfUser(L["Removed custom price source (%s) which has an invalid name."], name) CustomPrice.DeleteCustomPriceSource(name) end end end --- Register a built-in price source. -- @tparam string moduleName The name of the module which provides this source -- @tparam string key The key for this price source (i.e. DBMarket) -- @tparam string label The label which describes this price source for display to the user -- @tparam function callback The price source callback -- @tparam[opt=false] boolean fullLink Whether or not the full itemLink is required instead of just the itemString -- @param[opt] arg An additional argument which is passed to the callback -- @tparam[opt=false] booolean isVolatile Should be set if the price source may change without CustomPrice.OnSourceChange being called function CustomPrice.RegisterSource(moduleName, key, label, callback, fullLink, arg, isVolatile) tinsert(private.priceSourceKeys, strlower(key)) private.priceSourceInfo[strlower(key)] = { moduleName = moduleName, key = key, label = label, callback = callback, takeItemString = not fullLink, arg = arg, isVolatile = isVolatile, cache = {}, } end function CustomPrice.RegisterCustomSourceCallback(callback) tinsert(private.customSourceCallbacks, callback) end --- Create a new custom price source. -- @tparam string name The name of the custom price source -- @tparam string value The value of the custom price source function CustomPrice.CreateCustomPriceSource(name, value) assert(name ~= "") assert(gsub(name, "([a-z]+)", "") == "") assert(not private.settings.customPriceSources[name]) value = private.SanitizeCustomPriceString(value) private.settings.customPriceSources[name] = value for _, data in pairs(private.proxyData) do if data.origStr == value then data.customPriceSourceNames[name] = true end end wipe(private.customPriceCache) private.CallCustomSourceCallbacks() end --- Rename a custom price source. -- @tparam string oldName The old name of the custom price source -- @tparam string newName The new name of the custom price source function CustomPrice.RenameCustomPriceSource(oldName, newName) if oldName == newName then return end local value = private.settings.customPriceSources[oldName] assert(value) private.settings.customPriceSources[newName] = value private.settings.customPriceSources[oldName] = nil for _, data in pairs(private.proxyData) do data.customPriceSourceNames[oldName] = nil if data.origStr == value then data.customPriceSourceNames[newName] = true end end wipe(private.customPriceCache) CustomPrice.OnSourceChange(oldName) CustomPrice.OnSourceChange(newName) private.CallCustomSourceCallbacks() end --- Delete a custom price source. -- @tparam string name The name of the custom price source function CustomPrice.DeleteCustomPriceSource(name) assert(private.settings.customPriceSources[name]) private.settings.customPriceSources[name] = nil for _, data in pairs(private.proxyData) do data.customPriceSourceNames[name] = nil end wipe(private.customPriceCache) CustomPrice.OnSourceChange(name) private.CallCustomSourceCallbacks() end --- Sets the value of a custom price source. -- @tparam string name The name of the custom price source -- @tparam string value The value of the custom price source function CustomPrice.SetCustomPriceSource(name, value) assert(private.settings.customPriceSources[name]) value = private.SanitizeCustomPriceString(value) private.settings.customPriceSources[name] = value for _, data in pairs(private.proxyData) do data.customPriceSourceNames[name] = data.origStr == value or nil end wipe(private.customPriceCache) CustomPrice.OnSourceChange(name) end function CustomPrice.BulkCreateCustomPriceSourcesFromImport(customSources, replaceExisting) for name, value in pairs(customSources) do value = private.SanitizeCustomPriceString(value) assert(not private.settings.customPriceSources[name] or replaceExisting) if private.settings.customPriceSources[name] then CustomPrice.SetCustomPriceSource(name, value) else CustomPrice.CreateCustomPriceSource(name, value) end end end --- Print built-in price sources to chat. function CustomPrice.PrintSources() Log.PrintUser(L["Below is a list of all available price sources, along with a brief description of what they represent."]) local moduleList = TempTable.Acquire() for _, info in pairs(private.priceSourceInfo) do if not tContains(moduleList, info.moduleName) then tinsert(moduleList, info.moduleName) end end sort(moduleList, private.ModuleSortFunc) for _, module in ipairs(moduleList) do Log.PrintUserRaw("|cffffff00"..module..":|r") local lines = TempTable.Acquire() for _, info in pairs(private.priceSourceInfo) do if info.moduleName == module then tinsert(lines, format(" %s (%s)", Log.ColorUserAccentText(info.key), info.label)) end end sort(lines) for _, line in ipairs(lines) do Log.PrintfUserRaw(line) end TempTable.Release(lines) end TempTable.Release(moduleList) end function CustomPrice.GetDescription(key) local info = private.priceSourceInfo[key] return info and info.label or nil end --- Validate a custom price name. -- @tparam string customPriceName The custom price name -- @tparam[opt=false] boolean ignoreExistingCustomPriceSources Whether or not to ignore existing custom price sources -- @treturn boolean Whether or not the custom price name is valid function CustomPrice.ValidateName(customPriceName, ignoreExistingCustomPriceSources) -- custom price names must be lowercase if strlower(customPriceName) ~= customPriceName then return false, L["Custom price names can only contain lowercase letters."] end -- User defined price sources if not ignoreExistingCustomPriceSources and private.settings.customPriceSources[customPriceName] then return false, format(L["Custom price name %s already exists."], Theme.GetColor("INDICATOR"):ColorText(customPriceName)) end -- TSM defined price sources for source in CustomPrice.Iterator() do if strlower(source) == strlower(customPriceName) then return false, format(L["Custom price name %s is a reserved word which cannot be used."], Theme.GetColor("INDICATOR"):ColorText(customPriceName)) end end -- Math Functions for mathFunction in pairs(MATH_FUNCTIONS) do if strlower(mathFunction) == strlower(customPriceName) then return false, format(L["Custom price name %s is a reserved word which cannot be used."], Theme.GetColor("INDICATOR"):ColorText(customPriceName)) end end -- Comparisons for comparison in pairs(COMPARISONS) do if strlower(comparison) == strlower(customPriceName) then return false, format(L["Custom price name %s is a reserved word which cannot be used."], Theme.GetColor("INDICATOR"):ColorText(customPriceName)) end end return true end --- Validate a custom price string. -- @tparam string customPriceStr The custom price string -- @tparam ?table badPriceSources A table of price sources (as keys) which aren't allowed to be used -- @treturn boolean Whether or not the custom price string is valid -- @treturn ?string The error message if the custom price string was invalid function CustomPrice.Validate(customPriceStr, badPriceSources) local proxy, err = private.ParseCustomPrice(customPriceStr, badPriceSources) return proxy and true or false, err end --- Evaulates a custom price source for an item. -- @tparam string customPriceStr The custom price string -- @tparam string itemString The item to evalulate the custom price string for -- @tparam[opt=false] boolean allowZero If true, allows the result to be 0 -- @treturn ?number The resulting value or nil if the custom price string is invalid -- @treturn ?string The error message if the custom price string was invalid function CustomPrice.GetValue(customPriceStr, itemString, allowZero) local proxy, err = private.ParseCustomPrice(customPriceStr) if not proxy then return nil, err end local value = nil local mapReader = private.proxyData[proxy].mapReader if mapReader then value = mapReader[itemString] else value = proxy(itemString) end if not value or value < 0 or (not allowZero and value == 0) then return nil, L["No value was returned by the custom price for the specified item."] end return value end --- Gets a built-in price source's value for an item. -- @tparam string itemString The item to evalulate the price source for -- @tparam string key The key of the price source -- @treturn ?number The resulting value or nil if no price was found for the item function CustomPrice.GetItemPrice(itemString, key) itemString = ItemString.Get(itemString) if not itemString then return end local info = private.priceSourceInfo[strlower(key)] if not info then return end local cachedValue = info.cache[itemString] if cachedValue ~= nil then assert(not info.isVolatile) return cachedValue or nil end if not info.takeItemString then -- this price source does not take an itemString, so pass it an itemLink instead itemString = ItemInfo.GetLink(itemString) if not itemString then return end end local value = info.callback(itemString, info.arg) value = type(value) == "number" and value or nil if not info.isVolatile then info.cache[itemString] = value or false end return value end function CustomPrice.GetConversionsValue(sourceItemString, customPrice, method) if not customPrice then return end -- calculate disenchant value first if (not method or method == Conversions.METHOD.DISENCHANT) and ItemInfo.IsDisenchantable(sourceItemString) then local quality = ItemInfo.GetQuality(sourceItemString) local ilvl = ItemInfo.GetItemLevel(ItemString.GetBase(sourceItemString)) or 0 local classId = ItemInfo.GetClassId(sourceItemString) local value = 0 if quality and ilvl and classId then for targetItemString in DisenchantInfo.TargetItemIterator() do local amountOfMats = DisenchantInfo.GetTargetItemSourceInfo(targetItemString, classId, quality, ilvl) if amountOfMats then local matValue = CustomPrice.GetValue(customPrice, targetItemString) if not matValue or matValue == 0 then return end value = value + matValue * amountOfMats end end end value = floor(value) if value > 0 then return value end end -- calculate other conversion values local value = 0 for targetItemString, rate in Conversions.TargetItemsByMethodIterator(sourceItemString, method) do local matValue = CustomPrice.GetValue(customPrice, targetItemString) value = value + (matValue or 0) * rate end value = Math.Round(value) return value > 0 and value or nil end local function CustomPriceIteratorHelper(_, key) local info = private.priceSourceInfo[key] return info.key, info.moduleName, info.label end --- Iterate over the price sources. -- @return An iterator which provides the following fields: `key, moduleName, label` function CustomPrice.Iterator() return Table.Iterator(private.priceSourceKeys, CustomPriceIteratorHelper) end --- Should be called when the value of a registered source changes. -- @tparam string key The key of the price source -- @tparam[opt=nil] string itemString The item which the source changed for or nil if it changed for all items function CustomPrice.OnSourceChange(key, itemString) key = strlower(key) if private.priceSourceInfo[key] then if itemString then private.priceSourceInfo[key].cache[itemString] = nil else wipe(private.priceSourceInfo[key].cache) end end local isSpecificItem = itemString ~= ItemString.GetBase(itemString) or not ItemString.HasNonBase(itemString) for _, data in pairs(private.proxyData) do if data.map then local clearAll, clearItem, clearBaseItem = false, false, false if data.dependantPriceSources[key] then if not itemString then -- clear all items clearAll = true else for dependantItemString in pairs(data.dependantPriceSources[key]) do if dependantItemString == "_item" then if isSpecificItem then -- just clear the specific item clearItem = true else -- clear all items which have a matching baseItemString clearBaseItem = true end elseif dependantItemString == "_baseitem" then if not isSpecificItem then -- just clear the item which was passed (and was a base item) clearItem = true end else if dependantItemString == (isSpecificItem and itemString or ItemString.GetBase(dependantItemString)) then -- clear all items clearAll = true end end end end end if data.dependantPriceSources._convertPriceSource == key then -- TODO: could optimize this to only clear the items which have the specified item as a source item, but this should be pretty rare clearAll = true end data.map:SetCallbacksPaused(true) if clearAll then for mapItemString in data.map:Iterator() do data.map:ValueChanged(mapItemString) end for name in pairs(data.customPriceSourceNames) do CustomPrice.OnSourceChange(name) end elseif clearBaseItem then for mapItemString in data.map:Iterator() do if ItemString.GetBase(mapItemString) == itemString then data.map:ValueChanged(mapItemString) for name in pairs(data.customPriceSourceNames) do CustomPrice.OnSourceChange(name, mapItemString) end end end end if not clearAll and clearItem then data.map:ValueChanged(itemString) for name in pairs(data.customPriceSourceNames) do CustomPrice.OnSourceChange(name, itemString) end end data.map:SetCallbacksPaused(false) end end end function CustomPrice.DependantCustomSourceIterator(str) local result = TempTable.Acquire() local proxy = private.ParseCustomPrice(str) if proxy then local data = private.proxyData[proxy] for name, customSourceStr in pairs(TSM.db.global.userData.customPriceSources) do if data.dependantPriceSources[name] then tinsert(result, name) tinsert(result, customSourceStr) end end end return TempTable.Iterator(result, 2) end -- ============================================================================ -- Helper Functions -- ============================================================================ private.customPriceFunctions = { IsInvalid = IsInvalid, loopError = function(str) Log.PrintUser(L["Loop detected in the following custom price:"].." "..Log.ColorUserAccentText(str)) end, _avg = function(...) local total, count = 0, 0 for i = 1, select('#', ...) do local num = select(i, ...) if type(num) == "number" and not IsInvalid(num) then total = total + num count = count + 1 end end return count == 0 and Math.GetNan() or (total / count) end, _min = function(...) local minVal = nil for i = 1, select('#', ...) do local num = select(i, ...) if type(num) == "number" and not IsInvalid(num) and (not minVal or num < minVal) then minVal = num end end return minVal or Math.GetNan() end, _max = function(...) local maxVal = nil for i = 1, select('#', ...) do local num = select(i, ...) if type(num) == "number" and not IsInvalid(num) and (not maxVal or num > maxVal) then maxVal = num end end return maxVal or Math.GetNan() end, _first = function(...) for i = 1, select('#', ...) do local num = select(i, ...) if type(num) == "number" and not IsInvalid(num) then return num end end return Math.GetNan() end, _check = function(check, ...) return private.RunComparison(COMPARISONS.gt, check, 0, ...) end, _ifgt = function(...) return private.RunComparison(COMPARISONS.gt, ...) end, _ifgte = function(...) return private.RunComparison(COMPARISONS.gte, ...) end, _iflt = function(...) return private.RunComparison(COMPARISONS.lt, ...) end, _iflte = function(...) return private.RunComparison(COMPARISONS.lte, ...) end, _ifeq = function(...) return private.RunComparison(COMPARISONS.eq, ...) end, _round = function(...) if select('#', ...) < 1 or select('#', ...) > 2 then return Math.GetNan() end return Math.Round(...) end, _roundup = function(...) if select('#', ...) < 1 or select('#', ...) > 2 then return Math.GetNan() end return Math.Ceil(...) end, _rounddown = function(...) if select('#', ...) < 1 or select('#', ...) > 2 then return Math.GetNan() end return Math.Floor(...) end, _priceHelper = function(itemString, key, extraParam) itemString = ItemString.Get(itemString) if not itemString then return Math.GetNan() end if key == "convert" then local conversions = Conversions.GetSourceItems(itemString) if not conversions then return Math.GetNan() end local minPrice = nil for sourceItemString, rate in pairs(conversions) do local price = CustomPrice.GetItemPrice(sourceItemString, extraParam) if price then price = price / rate minPrice = min(minPrice or price, price) end end return minPrice or Math.GetNan() elseif extraParam == "custom" then local customPriceSourceStr = private.settings.customPriceSources[key] if not customPriceSourceStr then -- custom price source has since been deleted return Math.GetNan() end return CustomPrice.GetValue(customPriceSourceStr, itemString) or Math.GetNan() else return CustomPrice.GetItemPrice(itemString, key) or Math.GetNan() end end, } local PROXY_MT = { __index = function(self, index) local data = private.proxyData[self] if private.customPriceFunctions[index] then return private.customPriceFunctions[index] elseif index == "globalContext" or index == "origStr" then -- these keys can always be accessed return data[index] end if not data.isUnlocked then error("Attempt to access a hidden table", 2) end return data[index] end, __newindex = function(self, index, value) local data = private.proxyData[self] if not data.isUnlocked then error("Attempt to modify a hidden table", 2) end data[index] = value end, __call = function(self, item) local data = private.proxyData[self] data.isUnlocked = true local result = data.func(self, item, ItemString.GetBase(item)) or 0 data.isUnlocked = false return result end, __metatable = false, } function private.RunComparison(comparison, ...) if select('#', ...) > 4 then return Math.GetNan() end local leftCheck, rightCheck, ifValue, elseValue = ... leftCheck = leftCheck or Math.GetNan() rightCheck = rightCheck or Math.GetNan() ifValue = ifValue or Math.GetNan() elseValue = elseValue or Math.GetNan() if IsInvalid(leftCheck) or IsInvalid(rightCheck) then return Math.GetNan() elseif comparison == COMPARISONS.gt then return leftCheck > rightCheck and ifValue or elseValue elseif comparison == COMPARISONS.gte then return leftCheck >= rightCheck and ifValue or elseValue elseif comparison == COMPARISONS.lt then return leftCheck < rightCheck and ifValue or elseValue elseif comparison == COMPARISONS.lte then return leftCheck <= rightCheck and ifValue or elseValue elseif comparison == COMPARISONS.eq then return leftCheck == rightCheck and ifValue or elseValue else error("Error in custom price comparison") end end function private.CreateCustomPriceObj(func, origStr, dependantPriceSources, canCache) local customPriceSourceNames = {} for name, str in pairs(private.settings.customPriceSources) do if str == origStr then customPriceSourceNames[name] = true end end local proxy = newproxy(true) private.proxyData[proxy] = { isUnlocked = false, globalContext = private.context, origStr = origStr, customPriceSourceNames = customPriceSourceNames, func = func, dependantPriceSources = dependantPriceSources, map = nil, mapReader = nil, } local mt = getmetatable(proxy) for key, value in pairs(PROXY_MT) do mt[key] = value end if canCache then local map = SmartMap.New("string", "number", proxy) private.proxyData[proxy].map = map private.proxyData[proxy].mapReader = map:CreateReader() end return proxy end function private.ParsePriceString(str, badPriceSources) if tonumber(str) then return private.CreateCustomPriceObj(function() return Math.Round(tonumber(str)) end, str, {}, true) end local origStr = str -- put a space at the start and end str = " "..str.." " -- remove any colors around gold/silver/copper while true do local num1, num2, num3 str, num1 = 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]g\124r", "g") str, num2 = 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]s\124r", "s") str, num3 = 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]c\124r", "c") if num1 + num2 + num3 == 0 then break end end -- replace old itemStrings with the new format str = gsub(str, "([^h]i)tem:([0-9:%-]+)", "%1:%2") -- replace all formatted gold amount with their copper value local start = 1 local goldAmountContinue = true while goldAmountContinue do goldAmountContinue = false local minFindStart, minFindEnd, minFindSub = nil, nil, nil for _, pattern in ipairs(MONEY_PATTERNS) do local s, e, sub = strfind(str, pattern, start) if s and (not minFindStart or minFindStart > s + 1) then minFindStart = s + 1 minFindEnd = e minFindSub = sub end end if minFindStart then if strmatch(strsub(str, minFindStart-1, minFindStart-1), "[0-9a-zA-Z]") or strmatch(strsub(str, minFindEnd+1, minFindEnd+1), "[0-9a-zA-Z]") then return nil, L["Invalid gold value."] end local value = Money.FromString(minFindSub) if not value then -- sanity check return nil, L["Invalid function."] end local preStr = strsub(str, 1, minFindStart - 1) local postStr = strsub(str, minFindEnd + 1) str = preStr .. value .. postStr start = #str - #postStr + 1 goldAmountContinue = true end end -- remove up to 1 occurance of convert(priceSource[, item]) local convertPriceSource, convertItem local convertParams = strmatch(str, "[^a-z]convert%((.-)%)") if convertParams then local convertItemLink = strmatch(convertParams, "\124c.-\124r") local convertItemString = strmatch(convertParams, ITEM_STRING_PATTERN) if convertItemLink then -- check for itemLink in convert params convertItem = ItemString.Get(convertItemLink) if not convertItem then return nil, L["Invalid item link."] -- there's an invalid item link in the convertParams end convertPriceSource = strmatch(convertParams, "^ *(.-) *,") elseif convertItemString then -- check for itemString in convert params convertItem = convertItemString convertPriceSource = strmatch(convertParams, "^ *(.-) *,") else convertPriceSource = gsub(convertParams, ", *$", "") convertPriceSource = strtrim(convertPriceSource) end if convertPriceSource and ((badPriceSources and badPriceSources[convertPriceSource]) or convertPriceSource == "matprice") then return nil, format(L["You cannot use %s within convert() as part of this custom price."], convertPriceSource) end -- can't allow custom price sources in convert, so just check regular ones if not private.priceSourceInfo[convertPriceSource] then return nil, L["Invalid price source in convert."] end local num = 0 str, num = gsub(str, "([^a-z])convert%(.-%)", "%1~convert~") if num > 1 then return nil, L["A maximum of 1 convert() function is allowed."] end end while true do local itemLink = strmatch(str, "\124c.*\124r") if not itemLink then break end local _, endIndex = strfind(itemLink, "\124r") itemLink = strsub(itemLink, 1, endIndex) local itemString = ItemString.Get(itemLink) if not itemString then -- there's an invalid item link in the str return nil, L["Invalid item link."] end str = gsub(str, String.Escape(itemLink), itemString) end -- make sure there's spaces on either side of math operators str = gsub(str, "[%-%+%/%*%^]", " %1 ") -- make sure there's a space to the right of % signs str = gsub(str, "[%%]", "%1 ") -- convert percentages to decimal numbers str = gsub(str, "([0-9%.]+)%%", "( %1 / 100 ) *") -- ensure a space on either side of item strings and remove parentheses around them str = gsub(str, "%([ ]*("..ITEM_STRING_PATTERN..")[ ]*%)", " %1 ") -- ensure a space on either side of baseitem arguments and remove parentheses around them str = gsub(str, "%([ ]*(baseitem)[ ]*%)", " ~baseitem~ ") -- ensure a space on either side of parentheses and commas str = gsub(str, "[%(%),]", " %1 ") -- remove any occurances of more than one consecutive space str = gsub(str, " [ ]+", " ") -- ensure equal number of left/right parenthesis if select(2, gsub(str, "%(", "")) ~= select(2, gsub(str, "%)", "")) then return nil, L["Unbalanced parentheses."] end -- validate all words in the string local parts = String.SafeSplit(strtrim(str), " ") local i = 1 while i <= #parts do local word = parts[i] if strmatch(word, "^[%-%+%/%*%^]$") then if i == #parts then return nil, L["Invalid operator at end of custom price."] end -- valid math operator elseif badPriceSources and badPriceSources[word] then -- price source that's explicitly invalid return nil, format(L["You cannot use %s as part of this custom price."], word) elseif private.priceSourceInfo[word] or private.settings.customPriceSources[word] then -- make sure we're not trying to take the price source of a number if parts[i+1] == "(" and type(parts[i+2]) == "string" and not strfind(parts[i+2], "^[ip].*:") then return nil, L["Invalid parameter to price source."] end -- valid price source elseif tonumber(word) then -- make sure it's not an itemID (incorrect) if i > 2 and parts[i-1] == "(" and (private.priceSourceInfo[parts[i-2]] or private.settings.customPriceSources[parts[i-2]]) then return nil, L["Invalid parameter to price source."] end -- valid number elseif strmatch(word, "^"..ITEM_STRING_PATTERN.."$") or word == "~baseitem~" then -- make sure previous word was a price source if i > 1 and (private.priceSourceInfo[parts[i-1]] or private.settings.customPriceSources[parts[i-1]]) then -- valid item parameter else return nil, L["Item links may only be used as parameters to price sources."] end elseif word == "(" then -- empty parenthesis are not allowed if not parts[i+1] or parts[i+1] == ")" then return nil, L["Empty parentheses are not allowed"] end -- should never have ") (" if i > 1 and parts[i-1] == ")" then return nil, L["Missing operator between sets of parenthesis"] end elseif word == ")" then -- valid parenthesis elseif word == "," then if not parts[i+1] or parts[i+1] == ")" then return nil, L["Misplaced comma"] else -- we're hoping this is a valid comma within a function, will be caught by loadstring otherwise end elseif MATH_FUNCTIONS[word] then if not parts[i+1] or parts[i+1] ~= "(" then return nil, format(L["Invalid word: '%s'"], word) end -- valid math function elseif word == "~convert~" then -- valid convert statement elseif strtrim(word) == "" then -- harmless extra spaces else if strfind(word, "^%^1%^t%^") then -- this is an operation export that they tried to use as a custom price return nil, L["This looks like an exported operation and not a custom price."] elseif strfind(word, "global") then -- this is an old global price return nil, L["It looks like you're trying to reference an old global price source which no longer exists."] end return nil, format(L["Invalid word: '%s'"], word) end i = i + 1 end local canCache = true local dependantPriceSources = {} for key, value in pairs(private.settings.customPriceSources) do key = strlower(key) local usedKey = nil str, usedKey = private.PriceSourceParsingHelper(str, key, "custom", dependantPriceSources) if usedKey then local customPriceProxy, errMsg = private.ParseCustomPrice(value, badPriceSources) if not customPriceProxy then return nil, format(L["The '%s' custom price source is invalid."], key).." "..errMsg end canCache = canCache and private.proxyData[customPriceProxy].map end end for key, info in pairs(private.priceSourceInfo) do local usedKey = nil str, usedKey = private.PriceSourceParsingHelper(str, key, nil, dependantPriceSources) canCache = canCache and (not usedKey or not info.isVolatile) end -- replace "~convert~" appropriately if convertPriceSource then canCache = canCache and not private.priceSourceInfo[convertPriceSource].isVolatile dependantPriceSources._convertPriceSource = convertPriceSource convertItem = convertItem and ('"'..convertItem..'"') or "_item" str = gsub(str, "~convert~", format("self._priceHelper(%s, \"convert\", \"%s\")", convertItem, convertPriceSource)) end -- replace math functions with special custom function names for word, funcName in pairs(MATH_FUNCTIONS) do str = gsub(str, " "..word.." ", " "..funcName.." ") end -- finally, create and return the function local func, loadErr = loadstring(format(CUSTOM_PRICE_FUNC_TEMPLATE, str), "TSMCustomPrice: "..origStr) if loadErr then loadErr = gsub(strtrim(loadErr), "([^:]+):.", "") return nil, L["Invalid function."].." "..L["Details"]..": "..loadErr end local success = nil success, func = pcall(func) if not success then return nil, L["Invalid function."] end return private.CreateCustomPriceObj(func, origStr, dependantPriceSources, canCache) end function private.PriceSourceParsingHelper(str, key, extraArg, dependantPriceSources) extraArg = extraArg and (",\""..extraArg.."\"") or "" local numReplacements, usedKey = nil, false -- replace all " " occurances with the proper parameters (with the itemString) str, numReplacements = gsub(str, format(" %s (%s) ", key, ITEM_STRING_PATTERN), format(" self._priceHelper(\"%%1\",\"%s\"%s) ", key, extraArg)) if numReplacements > 0 then for itemString in gmatch(str, " self%._priceHelper%(\"("..ITEM_STRING_PATTERN..")\",\""..key.."\""..String.Escape(extraArg).."%) ") do -- add all the items used for this key dependantPriceSources[key] = dependantPriceSources[key] or {} dependantPriceSources[key][itemString] = true end usedKey = true end -- replace all " baseitem" occurances with the proper parameters (with _baseitem for the item) str, numReplacements = gsub(str, format(" %s ~baseitem~ ", key), format(" self._priceHelper(_baseitem,\"%s\"%s) ", key, extraArg)) if numReplacements > 0 then dependantPriceSources[key] = dependantPriceSources[key] or {} dependantPriceSources[key]._baseitem = true usedKey = true end -- replace all "" occurances with the proper parameters (with _item for the item) str, numReplacements = gsub(str, format(" %s ", key), format(" self._priceHelper(_item,\"%s\"%s) ", key, extraArg)) if numReplacements > 0 then dependantPriceSources[key] = dependantPriceSources[key] or {} dependantPriceSources[key]._item = true usedKey = true end return str, usedKey end function private.ParseCustomPrice(customPriceStr, badPriceSources) customPriceStr = private.SanitizeCustomPriceString(customPriceStr) if not customPriceStr or customPriceStr == "" then return nil, L["Empty price string."] end if badPriceSources then return private.ParsePriceString(customPriceStr, badPriceSources) end if not private.customPriceCache[customPriceStr] then private.customPriceCache[customPriceStr] = {private.ParsePriceString(customPriceStr)} end return unpack(private.customPriceCache[customPriceStr]) end function private.ModuleSortFunc(a, b) if a == "TSM" then return true elseif b == "TSM" then return false else return a < b end end function private.SanitizeCustomPriceString(customPriceStr) assert(customPriceStr) local result = private.sanitizeCache[customPriceStr] if not result then result = strlower(strtrim(tostring(customPriceStr))) result = Money.FromString(result) and gsub(result, String.Escape(LARGE_NUMBER_SEPERATOR), "") or result private.sanitizeCache[customPriceStr] = result end return result end function private.CallCustomSourceCallbacks() for _, callback in ipairs(private.customSourceCallbacks) do callback() end end