Module:WorkshopsRecipesData

From Against the Storm Official Wiki

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

---
--- Module for compiling recipe 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:
---
--- gradeStars = WorkshopsRecipesData.getRecipeGradeByID(recipeID).
---
--- This will return the number of stars of efficiency for the recipe. It is
--- preferable to call getter methods with the ID of the recipe rather than
--- retrieving the entire record for the recipe, so that your code stays
--- protected from variations in this module. The only way to look up recipes
--- is with their ID, so the getter methods all require an ID, which you can
--- get from workshops, for example, or from retrieving either of the lists of
--- recipe IDs from this module:
---
--- list = WorkshopsRecipesData.getAllRecipeIDsForProductID(productID)
--- list = WorkshopsRecipesData.getAllRecipeIDsWithIngredientID(ingredientID)
---
--- Then you can use those IDs with these getter methods:
---
--- * gradeStars = WorkshopsRecipesData.getRecipeGradeByID(recipeID)
--- * timeSeconds = WorkshopsRecipesData.getRecipeProductionTimeByID(recipeID)
--- * productID, stackSize = WorkshopsRecipesData.getRecipeProductByID(recipeID)
--- * optionID, stackSize = WorkshopsRecipesData.getRecipeOptionByID(recipeID, ingredientIndex, optionIndex)
---
--- There are three ways of getting the ingredients, based on the needs of your
--- code. The most specific way that does not return a table is the preferred
--- way of retrieving the data, because it is the most protected from changes
--- in this module. That is the way mentioned in the previous list, to get the
--- specified option at the specified index, great for i,j loops. It returns
--- nil when there are no more options.
---
--- The other ways return subsets of the data table (see below). These are
--- usable with ipairs iterators:
---
--- * ingredientsTable = WorkshopsRecipesData.getAllRecipeIngredientsByID(recipeID)
--- * optionsTable = WorkshopsRecipesData.getAllRecipeIngredientOptionsByID(recipeID, ingredientIndex)
---
--- As a last resort, you may use methods that begin with "getAll..." that
--- return tables of data (that is, other than the ones that get lists of
--- recipeIDs), but as mentioned they are less preferable, because the internal
--- workings of this module may change, and these tables may suddenly change
--- their structure, ruining your code. When in doubt, take the extra time to
--- write code that uses one of the specific getter methods (that begin with
--- "getRecipe...").
---
--- The data table for these workshop recipes has the following structure:
---
--- recipesTable = {
---		["recipe1_ID"] = {
--- 		["id"] = "recipe1_ID",
--- 		["gradeID"] = "Grade2",
--- 		["productionTime"] = 99, in seconds
--- 		["productID"] = "product1_ID"
---			["productStackSize"] = 99
--- 		["ingredients"] = {
--- 			[1] = { -- options for first ingredient
--- 				[1] = { ["stackSize"] == 99, ["goodID"] = "good1_ID" },
--- 				[2] = { ["stackSize"] == 99, ["goodID"] = "good2_ID" },
--- 				[3] = { ["stackSize"] == 99, ["goodID"] = "good3_ID" },
--- 				[4] = { ["stackSize"] == 99, ["goodID"] = "good4_ID" },
--- 				[5] = { ["stackSize"] == 99, ["goodID"] = "good5_ID" },
--- 				[6] = { ... } or missing if fewer
--- 			},
--- 			[2] = { -- options for second ingredient
--- 				[1] = { ["stackSize"] == 99, ["goodID"] = "good1_ID" }
--- 				-- will still have an inside table with [1] for non-optional ingredients like wood for planks
--- 			},
--- 			[4] = { ... }, or missing if fewer
--- 		}
---		},
---		["recipe2_ID"] = {
---			...
---		},
--- 	["recipe3_ID"] = {
--- 		...
--- 	},
---		...
--- }
---
--- @module WorkshopsRecipesData
local WorkshopsRecipesData = {}



local CsvUtils = require("Module:CsvUtils")



--region Private member variables

--- Main data tables, like this:  table[recipeID] = table containing recipe
--- data
local recipesTable

--- Lookup map. Built once and reused on subsequent calls within this session,
--- like this:  table[productGoodID] = { recipeID, recipeID, ... }
local mapProductIDsToRecipeIDs
--- Lookup map. Built once and reused on subsequent calls within this session,
--- like this:  table[ingredientGoodID] = { recipeID, recipeID, ... }
local mapIngredientIDsToRecipeIDs

--endregion



--region Private constants

local DATA_TEMPLATE_NAME = "Template:Workshops_Recipes_csv"

local INDEX_ID = "id"
local INDEX_GRADE = "gradeID"
local INDEX_PRODUCTION_TIME = "productionTime"
local INDEX_PRODUCT_ID = "productID"
local INDEX_PRODUCT_STACK_SIZE = "productStackSize"
local INDEX_INGREDIENTS = "ingredients"

local INDEX_INGREDIENTS_STACK_SIZE = "stackSize"
local INDEX_INGREDIENTS_GOOD_ID = "goodID"

local PATTERN_SPLIT_STACK_AND_ID = "(%d+)%s([%[%]%s%a]+)"
local PATTERN_CAPTURE_END_NUMBER = "Grade(%d)"

--endregion



--region Private methods

---
--- Creates a subtable of subtables containing the ingredient options for the
--- provided recipe.
---
--- @param originalRecipe table recipe from which to extract ingredient information
--- @param recipesHeaderLookup table header lookup built from the CSV data
--- @return table subtable with the ingredients options
local function makeIngredientsSubtable(recipeID, originalRecipe, recipesHeaderLookup)

	-- A few constants we'll need only within this function.
	local MAX_INGREDIENTS = 3
	local MAX_INGREDIENT_OPTIONS = 6
	local LOOKUP_INGREDIENT_BASE_STRING = "ingredient"
	local LOOKUP_OPTION_BASE_STRING = "Good"

	if not mapIngredientIDsToRecipeIDs then
		mapIngredientIDsToRecipeIDs = {}
	end

	-- The subtable of all ingredients and all their options.
	local ingredients = {}
	for i = 1, MAX_INGREDIENTS do

		-- A subtable for the options for this ingredient.
		local optionsList = {}
		for j = 1, MAX_INGREDIENT_OPTIONS do

			-- Finds the int index for each header like "ingredient1option1".
			local originalIndex = recipesHeaderLookup[LOOKUP_INGREDIENT_BASE_STRING .. i
					.. LOOKUP_OPTION_BASE_STRING .. j]

			-- Once it gets to a blank, can advance to the next option or
			-- next ingredient.
			local originalOption = originalRecipe[originalIndex]

			if originalOption ~= "" then
				-- Split the option string into number and ID.
				local optionStackSize, optionGoodID = originalOption:match(PATTERN_SPLIT_STACK_AND_ID)
				local newOption = {
					[INDEX_INGREDIENTS_STACK_SIZE] = tonumber(optionStackSize),
					[INDEX_INGREDIENTS_GOOD_ID] = optionGoodID
				}
				table.insert(optionsList, newOption)

				-- Store it to the lookup map too.
				if not mapIngredientIDsToRecipeIDs[optionGoodID] then
					mapIngredientIDsToRecipeIDs[optionGoodID] = {}
				end
				table.insert(mapIngredientIDsToRecipeIDs[optionGoodID], recipeID)
			end
		end

		-- Skip any blank lists.
		if #optionsList > 0 then
			table.insert(ingredients, optionsList)
		end
	end

	return ingredients
end



---
--- Transforms the originalRecipesTable returned from CSV processing to be more
--- conducive to member functions looking up data. Converts text strings
--- into subtables. Converts header row into field keys on every record and
--- stores records by id rather than arbitrary integer.
---
--- @param originalRecipesTable table of CSV-based data, with header row, data rows
--- @param recipesHeaderLookup table lookup table of headers to get indexes
--- @return table better structured with IDs as keys
local function restructureRecipesTable(originalRecipesTable, recipesHeaderLookup)

	-- A few constants we'll need only within this function.
	local DATA_ROWS = 2
	local INDEX_ORIGINAL_ID = 1
	local INDEX_ORIGINAL_GRADE = 2
	local INDEX_ORIGINAL_PRODUCTION_TIME = 3
	local INDEX_ORIGINAL_PRODUCT = 4

	mapProductIDsToRecipeIDs = {}

	local newRecipeTable = {}
	for _, originalRecipe in ipairs(originalRecipesTable[DATA_ROWS]) do

		local newRecipe = {}

		newRecipe[INDEX_ID] = originalRecipe[INDEX_ORIGINAL_ID]
		newRecipe[INDEX_GRADE] = tonumber(originalRecipe[INDEX_ORIGINAL_GRADE]:match(PATTERN_CAPTURE_END_NUMBER))
		newRecipe[INDEX_PRODUCTION_TIME] = tonumber(originalRecipe[INDEX_ORIGINAL_PRODUCTION_TIME])

		-- Split the product string into number and ID.
		local productStackSize, productID = originalRecipe[INDEX_ORIGINAL_PRODUCT]:match(PATTERN_SPLIT_STACK_AND_ID)
		newRecipe[INDEX_PRODUCT_STACK_SIZE] = tonumber(productStackSize)
		newRecipe[INDEX_PRODUCT_ID] = productID

		newRecipe[INDEX_INGREDIENTS] = makeIngredientsSubtable(newRecipe[INDEX_ID], originalRecipe, recipesHeaderLookup)

		-- Populate the lookup map of product IDs to the recipes that make them.
		if not mapProductIDsToRecipeIDs[productID] then
			mapProductIDsToRecipeIDs[productID] = {}
		end
		table.insert(mapProductIDsToRecipeIDs[productID], newRecipe[INDEX_ID])

		newRecipeTable[ newRecipe[INDEX_ID] ] = newRecipe
	end

	return newRecipeTable
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.
	local originalRecipesTable, recipesHeaderLookup = CsvUtils.extractTables(DATA_TEMPLATE_NAME)

	-- Now restructure to be more conducive.
	recipesTable = restructureRecipesTable(originalRecipesTable, recipesHeaderLookup)
end

--endregion



--region Public methods

---
--- Returns the whole table of data for the specified recipe. Instead of this,
--- you should probably be calling the individual getter methods.
---
--- Throws an error if called with a nil or empty string. Returns nil if the
--- specified recipe cannot be found.
---
---@param recipeID string the ID of the recipe
---@return table containing the data for the specified recipe, or nil if not found
function WorkshopsRecipesData.getAllDataForRecipeByID(recipeID)

	-- At runtime, this should never be nil or empty.
	if not recipeID or recipeID == "" then
		error("Parameter is nil or empty for recipe ID.")
	end

	if not recipesTable then
		loadData()
	end

	return recipesTable[recipeID]
end



---
--- Retrieve all IDs for recipes that result in the specified product.
---
--- @param productID string the ID of the product (good)
--- @return table the recipes that result in the product
function WorkshopsRecipesData.getAllRecipeIDsForProductID(productID)

	-- At runtime, this should never be nil or empty.
	if not productID or productID == "" then
		error("Parameter is nil or empty for product ID.")
	end

	if not recipesTable then
		loadData()
	end

	return mapProductIDsToRecipeIDs[productID]
end



---
--- Retrieves all IDs for recipes for which the specified good is an
--- ingredient or optional ingredient.
---
--- @param ingredientID string the ID of the ingredient (good)
--- @return table the recipes that could use that ingredient
function WorkshopsRecipesData.getAllRecipeIDsWithIngredientID(ingredientID)

	-- At runtime, this should never be nil or empty.
	if not ingredientID or ingredientID == "" then
		error("Parameter is nil or empty for ingredient ID.")
	end

	if not recipesTable then
		loadData()
	end

	return mapIngredientIDsToRecipeIDs[ingredientID]
end



---
--- Retrieves the grade code for the recipe specified by its ID.
---
--- Returns nil if the recipe ID was not found.
---
---@param recipeID string the ID of the recipe
---@return string the code of the grade.
function WorkshopsRecipesData.getRecipeGradeByID(recipeID)

	local recipe = WorkshopsRecipesData.getAllDataForRecipeByID(recipeID)

	if not recipe then
		return nil
	end

	return recipe[INDEX_GRADE]
end



---
--- Retrieves the product time for the recipe specified by its ID.
---
--- Returns nil if the recipe ID was not found.
---
---@param recipeID string the ID of the recipe
---@return number the production time, in seconds
function WorkshopsRecipesData.getRecipeProductionTimeByID(recipeID)

	local recipe = WorkshopsRecipesData.getAllDataForRecipeByID(recipeID)

	if not recipe then
		return nil
	end

	return recipe[INDEX_PRODUCTION_TIME]
end



---
--- Retrieves the product and stack size for the recipe specified by its ID.
---
--- Returns nil if the recipe ID was not found.
---
---@param recipeID string the ID of the recipe
---@return string the ID of the good produced by the recipe
---@return number the stack size produced
function WorkshopsRecipesData.getRecipeProductByID(recipeID)

	local recipe = WorkshopsRecipesData.getAllDataForRecipeByID(recipeID)

	if not recipe then
		return nil
	end

	return recipe[INDEX_PRODUCT_ID], recipe[INDEX_PRODUCT_STACK_SIZE]
end



---
--- Retrieves the subtable of data for ingredients for the recipe specified by
--- its ID.
---
--- Returns nil if the recipe ID was not found.
---
---@param recipeID string the ID of the recipe
---@return table of ingredients, options, stack sizes, and good IDs
function WorkshopsRecipesData.getAllRecipeIngredientsByID(recipeID)

	local recipe = WorkshopsRecipesData.getAllDataForRecipeByID(recipeID)

	if not recipe then
		return nil
	end

	return recipe[INDEX_INGREDIENTS]
end



---
--- Retrieves the number of ingredients (or groups of options) there are for
--- the recipe specified by its ID.
---
--- Returns nil if the recipe ID was not found.
---
---@param recipeID string the ID of the recipe
---@return number of ingredient groups
function WorkshopsRecipesData.getRecipeNumberOfIngredientsByID(recipeID)

	local recipe = WorkshopsRecipesData.getAllDataForRecipeByID(recipeID)

	if not recipe then
		return nil
	end

	return #recipe[INDEX_INGREDIENTS]
end



---
--- Retrieves the specified subtable of ingredients the recipe specified by
--- its ID.
---
--- Returns nil if the recipe ID was not found or if there is no ingredient at
--- the specified index
---
---@param recipeID string the ID of the recipe
---@param ingredientIndex number which ingredient to retrieve
---@return table of options for the specified ingredient, with stack sizes and good IDs, or nil if none
function WorkshopsRecipesData.getAllRecipeIngredientOptionsByID(recipeID, ingredientIndex)

	local recipe = WorkshopsRecipesData.getAllDataForRecipeByID(recipeID)

	if not recipe then
		return nil
	end

	return recipe[INDEX_INGREDIENTS][ingredientIndex]
end



---
--- Retrieves the number of options for the specified  ingredient.
---
--- Returns nil if the recipe ID was not found
---
---@param recipeID string the ID of the recipe
---@param ingredientIndex number which ingredient to retrieve
---@return number of options for the specified ingredient, or zero if no ingredient
function WorkshopsRecipesData.getRecipeNumberOfOptionsByID(recipeID, ingredientIndex)

	local recipe = WorkshopsRecipesData.getAllDataForRecipeByID(recipeID)

	if not recipe then
		return nil
	end

	local ingredientTable = recipe[INDEX_INGREDIENTS][ingredientIndex]
	if not ingredientTable then
		return 0
	end

	return #ingredientTable
end



---
--- Retrieves the specified option for the recipe specified by its ID.
---
--- Returns nil if the recipe ID was not found.
---
---@param recipeID string the ID of the recipe
---@param ingredientIndex number which ingredient to retrieve
---@param optionIndex number which option to retrieve
---@return string the ID of the good that is the specified option, or nil if none
---@return number the stack size of that good, or nil if none
function WorkshopsRecipesData.getRecipeOptionByID(recipeID, ingredientIndex, optionIndex)

	local recipe = WorkshopsRecipesData.getAllDataForRecipeByID(recipeID)

	if not recipe then
		return nil
	end

	local ingredient = WorkshopsRecipesData.getAllRecipeIngredientOptionsByID(recipeID, ingredientIndex)
	if not ingredient then
		return nil
	end

	local option = ingredient[optionIndex]
	if not option then
		return nil
	end

	return option[INDEX_INGREDIENTS_GOOD_ID], option[INDEX_INGREDIENTS_STACK_SIZE]
end

--endregion

return WorkshopsRecipesData