Module:PerksData

--- --- Module for compiling perks information from wiki data sources. Restructures --- the flat data tables that are produced from CsvUtils to make them more --- conducive to Lua methods that need to display the information. --- --- The standard way of using this module is a call like the following: --- --- iconFilename = PerksData.getIconFilename(perkID) --- --- This will return a string corresponding to the filename of the icon of the --- perk, including the .png extension. It is preferable to call getter methods --- with the ID of the perk rather than retrieving the entire record for the --- perk, so that your code stays protected from variations in this module. --- The getter methods are all called with the ID of the perk, not their --- plain-language display name, so there are many duplicates. --- --- * plainLanguageName = PerksData.getNameByID(id) --- * longDescription = PerksData.getDescriptionByID(id) --- * iconFilename = PerksData.getIconByID(id) --- * isFromCornerstone = PerksData.isFromCornerstoneByID(id) --- * isFromTrader = PerksData.isFromTraderByID(id) --- * isFromOrder = PerksData.isFromOrderByID(id) --- * isFromEvent = PerksData.isFromEventByID(id) --- * rarityConstant = PerksData.getRarityByID(id) --- * isMatch = PerksData.isRarityMatchByID(id, rarity) --- * isUncommon = PerksData.isRarityUncommonByID(id) --- * isRare = PerksData.isRarityRareByID(id) --- * isEpic = PerksData.isRarityEpicByID(id) --- * isLegendary = PerksData.isRarityLegendaryByID(id) --- * price = PerksData.getPriceByID(id) --- --- Note that display names, or plain language names, are very likely to have --- duplicates in the list of perks. --- --- You can also use this method, although it is more likely to break. --- --- * enumValue = PerksData.getRarity(id) --- --- The rarity constants are public and may be used. You are encouraged to --- refer directly to the constants themselves rather than comparing them to --- what might seem to be appropriate string literals, like `== "Epic"`. For --- example, use this kind of statement instead. --- --- if PerksData.getRarity(perkID) == PerksData.RARITY_EPIC then --- --- Then render your own string literal to display. This guidance may change --- in the future. --- --- There is a method to retrieve the table of sources, but it is advised to --- instead use the getter methods isFromTrader, isFromOrder, and isFromEvent --- instead. If you must get the table, there is a getAll method: --- --- * sourcesTable = PerksData.getAllSources(id) --- --- As a last resort, or if you need to transform the data structure, you can --- call the method getAllDataForPerkByID(id). This returns a whole record from --- the data table. --- --- The data table for perks has the following structure: --- --- perksTable = { --- 	["perk1_ID"] = { --- 		["id"] = "perk1_ID", --- 		["displayName"] = "Plain Language Name", --- 		["description"] = "A long string with some HTML entities too.", --- 		["iconFilename"] = "Icon_filename.png", --- 		["sources"] = { --- 			[1] = SOURCE_ORDER, for examples --- 			[2] = SOURCE_RELIC, --- 			[3] = SOURCE_CORNERSTONE, --- 			[4] = ... or missing if fewer --- 		}, --- 		["rarity"] = PerksData.RARITY_EPIC, for example --- 		["price"] = 99 --- 	}, --- 	["perk2_ID"] = { --- 		... --- 	}, --- 	["perk3_ID"] = { --- 		... --- 	}, --- 	... --- } --- --- @module PerksData local PerksData = {}

local CsvUtils = require("Module:CsvUtils")

--region Private member variables

--- Main data tables, like this: table[ID] = table containing data for that ID local perksTable

--- Supporting table, list of names table[index] = ID. local perksIDs

--- Lookup maps. Built once and reused on all subsequent calls within this --- session, like this: table[displayName] = ID local mapNamesToIDs

--endregion

--region Public enums

PerksData.RARITY_UNCOMMON = "Uncommon" PerksData.RARITY_RARE = "Rare" PerksData.RARITY_EPIC = "Epic" PerksData.RARITY_LEGENDARY = "Legendary" PerksData.RARITY_MYTHIC = "Mythic" PerksData.NONE = "None"

PerksData.SOURCE_ALTAR = "Altar" PerksData.SOURCE_CORNERSTONE = "Cornerstone" PerksData.SOURCE_ORDER = "Order" PerksData.SOURCE_RELIC = "Relic" PerksData.SOURCE_TRADER = "Trader"

--endregion

--region Private constants

local DATA_TEMPLATE_NAME = "Template:Perks_csv"

local VALUE_DIVISOR = 10

local INDEX_ID = "id" local INDEX_NAME = "displayName" local INDEX_DESCRIPTION = "description" local INDEX_ICON_FILENAME = "iconFilename" local INDEX_SOURCES = "sources" local INDEX_RARITY = "rarity" local INDEX_PRICE = "price"

local SWITCH_RARITIES_CHECK = { [PerksData.RARITY_UNCOMMON] = true, [PerksData.RARITY_RARE] = true, [PerksData.RARITY_EPIC] = true, [PerksData.RARITY_LEGENDARY] = true, [PerksData.RARITY_MYTHIC] = true, }

local NORMALIZED_TEXT_RARITIES = { ["uncommon"] = PerksData.RARITY_UNCOMMON, ["rare"] = PerksData.RARITY_RARE, ["epic"] = PerksData.RARITY_EPIC, ["legendary"] = PerksData.RARITY_LEGENDARY, ["mythic"] = PerksData.RARITY_MYTHIC }

local RARITIES_SORT_ORDER = { [PerksData.RARITY_UNCOMMON] = 1, [PerksData.RARITY_RARE] = 2, [PerksData.RARITY_EPIC] = 3, [PerksData.RARITY_LEGENDARY] = 4, [PerksData.RARITY_MYTHIC] = 5, }

local SWITCH_SOURCES_CHECK = { [PerksData.SOURCE_ALTAR] = true, [PerksData.SOURCE_CORNERSTONE] = true, [PerksData.SOURCE_ORDER] = true, [PerksData.SOURCE_RELIC] = true, [PerksData.SOURCE_TRADER] = true, }

local NORMALIZED_TEXT_SOURCES = { ["altar"] = PerksData.SOURCE_ALTAR, ["cornerstone"] = PerksData.SOURCE_CORNERSTONE, ["order"] = PerksData.SOURCE_ORDER, ["relic"] = PerksData.SOURCE_RELIC, ["trader"] = PerksData.SOURCE_TRADER, }

local ERROR_MESSAGE_ID_MISSING = "PerksData cannot compare IDs when one or more of the IDs were missing." local ERROR_PREFIX_NAME_NOT_FOUND = "PerksData attempted to retrieve the display name of a Perk or Cornerstones, but it was not found. Double-check that the data is up-to-date and loaded correctly:"

--endregion

--region Private methods

--- --- Removes some markup and processing leftovers from the CSV extraction. --- ---@param originalPerkDescription string the perk's description ---@return string the cleaned description local function cleanDescription(originalPerkDescription)

-- replace any duplicated quotes originalPerkDescription = originalPerkDescription:gsub("&quot;&quot;", '"')

-- remove any leftover quotes return originalPerkDescription:gsub("&quot;", '"') end

--- --- Creates a new subtable containing the sources of the specified perk. --- ---@param originalPerkSources string list from which to extract sources ---@return table subtable with the sources local function makeSourcesSubtable(originalPerkSources)

local sources = {}

-- Sub in any HTML entities for actual commas. This was originally critical -- to have the CSV data correctly parsed, but now we have to undo it to -- correctly split up these values. originalPerkSources = originalPerkSources:gsub("&comma;", ",") originalPerkSources = originalPerkSources:gsub("&quot;", "")

-- Split on commas. We can use the + here because there are no blanks. for sourceName in originalPerkSources:gmatch("([^,]+)") do		sourceName = sourceName:gsub("%s+", "") table.insert(sources, sourceName) end

return sources end

--- --- Transforms the oldPerksTable returned from CSV processing to be more --- conducive to member functions looking up data. Essentially, we convert the --- text strings into tables with the same base name and an index. --- ---@param originalPerksTable table of CSV-based table, with a header row, data rows ---@return table better structured with IDs as keys local function restructurePerksTable(originalPerksTable)

-- A few constants we'll need only within this function. local DATA_ROWS = 2 local INDEX_OLD_ID = 1 local INDEX_OLD_NAME = 2 local INDEX_OLD_DESCRIPTION = 3 local INDEX_OLD_ICON_FILENAME = 4 local INDEX_OLD_SOURCES = 5 local INDEX_OLD_RARITY = 6 local INDEX_OLD_PRICE = 7

perksIDs = {} mapNamesToIDs = {}

local newPerksTable = {} for _, originalPerk in ipairs(originalPerksTable[DATA_ROWS]) do

-- Copy over the content, mapping unhelpful indexes into header keys local newPerk = {} newPerk[INDEX_ID] = originalPerk[INDEX_OLD_ID] newPerk[INDEX_NAME] = originalPerk[INDEX_OLD_NAME] newPerk[INDEX_DESCRIPTION] = cleanDescription(originalPerk[INDEX_OLD_DESCRIPTION]) newPerk[INDEX_ICON_FILENAME] = originalPerk[INDEX_OLD_ICON_FILENAME] .. ".png" newPerk[INDEX_RARITY] = originalPerk[INDEX_OLD_RARITY] newPerk[INDEX_PRICE] = originalPerk[INDEX_OLD_PRICE] / VALUE_DIVISOR

newPerk[INDEX_SOURCES] = makeSourcesSubtable(originalPerk[INDEX_OLD_SOURCES])

newPerksTable[ newPerk[INDEX_ID] ] = newPerk

table.insert(perksIDs, newPerk[INDEX_ID])

-- Also populate the map for looking up IDs with display names if not mapNamesToIDs[ newPerk[INDEX_NAME] ] then mapNamesToIDs[ newPerk[INDEX_NAME] ] = {} end table.insert(mapNamesToIDs[ newPerk[INDEX_NAME] ], newPerk[INDEX_ID]) end

return newPerksTable end

--- --- Data loader function that uses the utility module and restructures the data --- to be easier to access for invoking methods and later method calls. This --- method is automatically called by all public member functions if the main --- data table has not yet been populated in the current session. local function loadData

-- Utility module retrieves the data as basic, flat lua tables. -- Doesn't use the header lookup. local originalPerksTable, _ = CsvUtils.luaTableFromCSV(CsvUtils.extractCSV(DATA_TEMPLATE_NAME))

-- Now restructure to be more conducive. perksTable = restructurePerksTable(originalPerksTable) end

--endregion

--region Public methods

---isPerkIDValid ---@param perkID string the unique identifier of a Perk or Cornerstone ---@return boolean true if there is a record with the specified ID function PerksData.isPerkIDValid(perkID)

if not perkID or "" == perkID then return false else local record = PerksData.getAllDataForPerkByID(perkID) return record ~= nil end end

--- --- Retrieve the whole table of data for the specified perk. Instead of this, --- you should probably be calling the individual getter methods. --- --- Throws an error if called with nil or empty string. Returns nil if the --- specified perk cannot be found. --- --- @param id string ID of the perk --- @return table containing the data for the specified perk with key-value pairs, or nil if not found function PerksData.getAllDataForPerkByID(id)

-- At runtime, this should never be nil or empty. if not id or id == "" then error("Parameter is nil or empty for the perk's ID.") end

if not perksTable then loadData end

return perksTable[id] end

--- --- Get the IDs of all the Perks that are named the same thing. --- --- Returns a table of IDs, since it's unknown how many there could be. --- --- @param displayName string plain-language name of the perk --- @return table of IDs of the Perk found, or an empty table if none found function PerksData.getAllPerkIDsByName(displayName)

if not perksTable then loadData end

local foundPerkIDs = mapNamesToIDs[displayName]

if not foundPerkIDs or #foundPerkIDs == 0 then return {} end

return foundPerkIDs end

--- --- Retrieves the display name of the perk specified by its ID. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@return string the name of the perk function PerksData.getNameByID(perkID)

local perk = PerksData.getAllDataForPerkByID(perkID)

if not perk then return nil end

return perk[INDEX_NAME] end

--- --- Retrieves the description of the perk specified by its ID. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@return string the description of the perk function PerksData.getDescriptionByID(perkID)

local perk = PerksData.getAllDataForPerkByID(perkID)

if not perk then return nil end

return perk[INDEX_DESCRIPTION] end

--- --- Retrieves the display name of the perk specified by its ID. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@return string the icon filename of the perk, including the .png extension function PerksData.getIconByID(perkID)

local perk = PerksData.getAllDataForPerkByID(perkID)

if not perk then return nil end

return perk[INDEX_ICON_FILENAME] end

--- --- Retrieves all the sources of the perk specified by its ID. --- --- Returns nil if the perk was not found. --- ---@param perkID string the ID of the perk ---@return table of sources, strings function PerksData.getAllSourcesByID(perkID)

local perk = PerksData.getAllDataForPerkByID(perkID)

if not perk then return nil end

return perk[INDEX_SOURCES] end

--- --- Retrieves the number of sources of the perk specified by its ID. --- --- Returns nil if the perk was not found. --- ---@param perkID string the ID of the perk ---@return number of sources for this perk function PerksData.getNumberOfSourcesByID(perkID)

local perk = PerksData.getAllDataForPerkByID(perkID)

if not perk then return nil end

local sourcesTable = perk[INDEX_SOURCES]

if not sourcesTable then return nil end

return #sourcesTable end

--- --- Checks all the sources to see whether the specified source is found. --- ---@param function PerksData.isAnySource(perkID, sourceToCheck)

local perk = PerksData.getAllDataForPerkByID(perkID)

if not perk then return nil end

for _, source in ipairs(perk[INDEX_SOURCES]) do

if source == sourceToCheck then return true end end

return false end

---isFromAltarByID ---@param perkID string the ID of the perk ---@return boolean whether the perk comes from the Forsaken Altar function PerksData.isFromAltarByID(perkID)

return PerksData.isAnySource(perkID, PerksData.SOURCE_ALTAR) end

--- --- Retrieves whether the perk specified by its ID comes from a Cornerstone. --- --- Returns nil if the perk was not found. --- ---@param perkID string the ID of the perk ---@return boolean whether the perk comes from a Cornerstone function PerksData.isFromCornerstoneByID(perkID)

return PerksData.isAnySource(perkID, PerksData.SOURCE_CORNERSTONE) end

--- --- Retrieves whether the perk specified by its ID comes from a Order. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@return boolean whether the perk comes from a Order function PerksData.isFromOrderByID(perkID)

return PerksData.isAnySource(perkID, PerksData.SOURCE_ORDER) end

--- --- Retrieves whether the perk specified by its ID comes from an Event. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@return boolean whether the perk comes from an Event function PerksData.isFromEventByID(perkID)

return PerksData.isAnySource(perkID, PerksData.SOURCE_RELIC) end

--- --- Retrieves whether the perk specified by its ID comes from a Trader. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@return boolean whether the perk comes from a Trader function PerksData.isFromTraderByID(perkID)

return PerksData.isAnySource(perkID, PerksData.SOURCE_TRADER) end

--- --- Retrieves the rarity of the perk specified by its ID. --- --- Returns nil if the perk was not found. --- ---@param perkID string the ID of the perk ---@return string one of the rarity constants function PerksData.getRarityByID(perkID)

local perk = PerksData.getAllDataForPerkByID(perkID)

if not perk then return nil end

return perk[INDEX_RARITY] end

--- --- Retrieves whether the perk specified by its ID is of the given rarity. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@param rarityToCheck string of the rarity constants ---@return boolean whether the perk has the specified rarity function PerksData.isRarityMatchByID(perkID, rarityToCheck)

local perk = PerksData.getAllDataForPerkByID(perkID)

if not perk then return nil end

return perk[INDEX_RARITY] == rarityToCheck end

--- --- Retrieves whether the perk specified by its ID is of Uncommon rarity. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@return boolean whether the perk is Uncommon function PerksData.isRarityUncommonByID(perkID)

return PerksData.isRarityMatchByID(perkID, PerksData.RARITY_UNCOMMON) end

--- --- Retrieves whether the perk specified by its ID is of Rare rarity. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@return boolean whether the perk is Rare function PerksData.isRarityRareByID(perkID)

return PerksData.isRarityMatchByID(perkID, PerksData.RARITY_RARE) end

--- --- Retrieves whether the perk specified by its ID is of Epic rarity. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@return boolean whether the perk is Epic function PerksData.isRarityEpicByID(perkID)

return PerksData.isRarityMatchByID(perkID, PerksData.RARITY_EPIC) end

--- --- Retrieves whether the perk specified by its ID is of Legendary rarity. --- --- Returns nil if the perk was not found --- ---@param perkID string the ID of the perk ---@return boolean whether the perk is Legendary function PerksData.isRarityLegendaryByID(perkID)

return PerksData.isRarityMatchByID(perkID, PerksData.RARITY_LEGENDARY) end

--- --- Retrieves the price of the perk specified by its ID. --- --- Returns nil if the perk was not found. --- ---@param perkID string the ID of the perk ---@return number the price of the perk from a trader function PerksData.getPriceByID(perkID)

local perk = PerksData.getAllDataForPerkByID(perkID)

if not perk then return nil end

return perk[INDEX_PRICE] end

--- --- Loop through all perks and return those whose names contain the --- specified search term. --- ---@param searchTerm string terms to search for ---@return table list of perkIDs with a match in their description function PerksData.getAllPerkIDsWhereName(searchTerm)

if not searchTerm then return {} end

if not perksTable then loadData end

local list = {} for perkID, perk in pairs(perksTable) do

if string.lower(perk[INDEX_NAME]):find(string.lower(searchTerm)) then table.insert(list, perkID) end end

return list end

--- --- Loop through all perks and return those whose descriptions contain the --- specified search term. --- ---@param searchTerm string terms to search for ---@return table list of perkIDs with a match in their description function PerksData.getAllPerkIDsWhereDescription(searchTerm)

if not searchTerm then return {} end

if not perksTable then loadData end

local list = {} for perkID, perk in pairs(perksTable) do

if string.lower(perk[INDEX_DESCRIPTION]):find(string.lower(searchTerm)) then table.insert(list, perkID) end end

return list end

function PerksData.getAllPerkIDs

if not perksTable then loadData end

return perksIDs end

--- Gets all IDs for all perks, but filtered to the specified rarity and source. --- ---@param rarityToFilter string the rarity to filter to, should be normalized already ---@param sourceToFilter string the source to filter to, should be normalized already ---@return table an array of filtered IDs function PerksData.getAllPerkIDsFilteredByRarityAndSource(rarityToFilter, sourceToFilter)

if not perksTable then loadData end

-- Don't use the internal functions, because we need to return an empty list without any errors if one or the other wasn't provided. local filteredIDs = {} for id, perk in pairs(perksTable) do		if not rarityToFilter or "" == rarityToFilter or perk[INDEX_RARITY] == rarityToFilter then for _, perkSource in ipairs(perk[INDEX_SOURCES]) do				if not sourceToFilter or "" == sourceToFilter or perkSource == sourceToFilter then table.insert(filteredIDs, id) break end end end end

return filteredIDs end

--- A custom sorting function so that perks can be listed alphabetically via table.sort(yourTable, PerksData.compareNames). Public so controllers and other data models might reuse this. --- --- Throws errors if the IDs do not correspond to perks in the data. --- ---@param perkID1 string the ID of the first perk to compare ---@param perkID2 string the ID of the second perk to compare ---@return boolean true if the first perk should come first in the list function PerksData.compareNames(perkID1, perkID2)

if not perkID1 or "" == perkID1 or not perkID2 or "" == perkID2 then error(ERROR_MESSAGE_ID_MISSING) end

if not perksTable then loadData end

local perk1 = perksTable[perkID1] local perk2 = perksTable[perkID2]

if not perk1 then error(ERROR_PREFIX_NAME_NOT_FOUND .. " id=" .. perkID1 .. ".") end if not perk2 then error(ERROR_PREFIX_NAME_NOT_FOUND .. " id=" .. perkID2 .. ".") end

local name1 = perk1[INDEX_NAME] local name2 = perk2[INDEX_NAME]

if not name1 then error(ERROR_PREFIX_NAME_NOT_FOUND .. " id=" .. perkID1 .. ".") end if not name2 then error(ERROR_PREFIX_NAME_NOT_FOUND .. " id=" .. perkID2 .. ".") end

if name1 == name2 then return RARITIES_SORT_ORDER[perk1[INDEX_RARITY]] < RARITIES_SORT_ORDER[perk2[INDEX_RARITY]] else return name1 < name2 end

end

--- Standardizes the display of a string name of one of Perks' rarities. --- --- If the provided rarity is nil or not an accepted term (for example misspelled), this will return nil. --- ---@param rarityString string the descriptive name of the rarity of a perk or cornerstone ---@return string that same rarity, normalized to title case function PerksData.normalizeRarityText(rarityString)

if not rarityString or "" == rarityString then return nil end

local rarity = string.lower(rarityString) if not rarity or SWITCH_RARITIES_CHECK[rarity] then return nil end

return NORMALIZED_TEXT_RARITIES[rarity] end

--- Standardizes the display of a string name of one of Perks' sources. --- --- If the provided source is nil or not an accepted term (for example misspelled), this will return nil. --- ---@param sourceString string the descriptive name of the source of a perk or cornerstone ---@return string that same source, normalized to title case function PerksData.normalizeSourceText(sourceString)

if not sourceString or "" == sourceString then return nil end

local source = string.lower(sourceString) if not source or SWITCH_SOURCES_CHECK[source] then return nil end

return NORMALIZED_TEXT_SOURCES[source] end

--endregion

return PerksData