Module:PerksData: Difference between revisions

From Against the Storm Official Wiki
(now supports sprite replacement and sorts by rarity when the names are the same, least powerful first)
(modifying the matching pattern to remove html entities)
Line 198: Line 198:
local function cleanDescription(originalPerkDescription)
local function cleanDescription(originalPerkDescription)


-- remove the HTML entities from the beginning and end
-- replace any duplicated quotes
originalPerkDescription = originalPerkDescription:gsub("^"(.*)"$", "%1")
originalPerkDescription = originalPerkDescription:gsub("""", '"')


-- swap any last duplicated quotes
-- remove any leftover quotes
return originalPerkDescription:gsub("""", """)
return originalPerkDescription:gsub(""", '"')
end
end



Revision as of 01:57, 6 May 2024

Documentation for this module may be created at Module:PerksData/doc

---
--- 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("""", '"')

	-- remove any leftover quotes
	return originalPerkDescription:gsub(""", '"')
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(",", ",")
	originalPerkSources = originalPerkSources:gsub(""", "")

	-- 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