Module:RecipeController2: Difference between revisions

From Against the Storm Official Wiki
m (next up, the list version!)
(shifting the grade storing to help it store in a more natural order, whenever it can at least. next step is the list view)
Line 56: Line 56:
[3] = '3Star',
[3] = '3Star',
}
}
--- Transform the grade only when using the value as an index, to help it sort better whenever possible.
local STORE_GRADES = {
[0] = 1,
[1] = 2,
[2] = 3,
[3] = 4,
}


local MAX_INGREDIENTS = 3
local MAX_INGREDIENTS = 3
Line 1,001: Line 1,012:
productName = GoodsData.getName(productID)
productName = GoodsData.getName(productID)
end
end


-- Now that we have everything extracted from rawRecipeTable for this pair, load it into recipeObjectTable, whether as a new Recipe object or adding a building to an existing Recipe object if one already exists. Recipes are uniquely identified by the 3-way combination of product, grade, and product amount.
-- Now that we have everything extracted from rawRecipeTable for this pair, load it into recipeObjectTable, whether as a new Recipe object or adding a building to an existing Recipe object if one already exists. Recipes are uniquely identified by the 3-way combination of product, grade, and product amount.
Line 1,006: Line 1,018:
recipeObjectTable[productName] = {}
recipeObjectTable[productName] = {}
end
end
if not recipeObjectTable[productName][grade] then
-- Transform the grade values to store them in a natural order.
recipeObjectTable[productName][grade] = {}
if not recipeObjectTable[productName][STORE_GRADES[grade]] then
recipeObjectTable[productName][STORE_GRADES[grade]] = {}
end
end
if not recipeObjectTable[productName][grade][productAmount] then
if not recipeObjectTable[productName][STORE_GRADES[grade]][productAmount] then
-- Create a new Recipe object at this place in the table.
-- Create a new Recipe object at this place in the table.
recipeObjectTable[productName][grade][productAmount] = RecipeController.Recipe.new( { buildingName }, grade, time, productID, productAmount, ingredientsTable)
recipeObjectTable[productName][STORE_GRADES[grade]][productAmount] = RecipeController.Recipe.new( { buildingName }, grade, time, productID, productAmount, ingredientsTable)
else
else
-- Add the building to the existing Recipe object at this place in the table.
-- Add the building to the existing Recipe object at this place in the table.
recipeObjectTable[productName][grade][productAmount]:addBuilding(buildingName)
recipeObjectTable[productName][STORE_GRADES[grade]][productAmount]:addBuilding(buildingName)
end
end
end
end
Line 1,030: Line 1,043:
---@param requiredIngredient string the name of the ingredient, or nil if any
---@param requiredIngredient string the name of the ingredient, or nil if any
---@return table a 3-factor compiled table of Recipe objects, [product][grade][amount]
---@return table a 3-factor compiled table of Recipe objects, [product][grade][amount]
function RecipeController.getRecipesFromAllDataModels(requiredProduct, requiredBuilding, requiredIngredient)
local function getRecipesFromAllDataModels(requiredProduct, requiredBuilding, requiredIngredient)


--Resolve names to IDs, start them all as nil as wildcards.
--Resolve names to IDs, start them all as nil as wildcards.
Line 1,136: Line 1,149:
---buildMiddle
---buildMiddle
---Calls the view to render table rows for each Recipe object and links to buildings and resources within the table rows.
---Calls the view to render table rows for each Recipe object and links to buildings and resources within the table rows.
---
---Benchmarking: ~0.005 seconds
---
---
---@param frame table MediaWiki template context
---@param frame table MediaWiki template context
Line 1,144: Line 1,159:
---@param _ string name of ingredient, or nil if any
---@param _ string name of ingredient, or nil if any
---@return string a long string of wiki markup
---@return string a long string of wiki markup
function RecipeController.buildMiddle(frame, recipeList, maxIngredients, requiredProduct, requiredBuilding, _)
local function buildMiddle(frame, recipeList, maxIngredients, requiredProduct, requiredBuilding, _)


local ret = ""
local ret = ""
Line 1,388: Line 1,403:


-- recipeList is a 3-factor array of Recipe objects, by [product][grade][stackSize]
-- recipeList is a 3-factor array of Recipe objects, by [product][grade][stackSize]
local recipeList = RecipeController.getRecipesFromAllDataModels(requiredProduct, requiredBuilding, requiredIngredient)
local recipeList = getRecipesFromAllDataModels(requiredProduct, requiredBuilding, requiredIngredient)


local numRecipes = countRecipes(recipeList)
local numRecipes = countRecipes(recipeList)
Line 1,402: Line 1,417:
}
}


local retMiddle = RecipeController.buildMiddle(frame, recipeList, maxIngredients, requiredProduct, requiredBuilding, requiredIngredient)
local retMiddle = buildMiddle(frame, recipeList, maxIngredients, requiredProduct, requiredBuilding, requiredIngredient)


local retEnd frame:expandTemplate{
local retEnd = frame:expandTemplate{
title = VIEW_TEMPLATE_END,
title = VIEW_TEMPLATE_END,
args = {},
args = {},

Revision as of 22:19, 27 October 2024

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

--- @module RecipeController
local RecipeController = {}



--region Dependencies

local DataModelsArray = {
	require("Module:CampsData2"),
	require("Module:FarmsData2"),
	require("Module:FishingData"),
	require("Module:GatheringData"),
	require("Module:InstitutionsData2"),
	require("Module:RainCollectorsData"),
	require("Module:WorkshopsData2"),
}

local GoodsData = require("Module:GoodsData")
local BaseDataModel = require("Module:BaseDataModel")

local VIEW_TEMPLATE_START = "Recipe/view"
local VIEW_TEMPLATE_ROW = "Recipe/view/row"
local VIEW_TEMPLATE_END = "Recipe/view/end"

local VIEW_BUILDING_LINK = "Building_link/view"
local VIEW_RESOURCE_LINK = "Resource_link/view"

--endregion



--region Private constants

local INDEX_RECIPE_BUILDINGS_ARRAY = "buildingsArray"
local INDEX_RECIPE_GRADE = "grade"
local INDEX_RECIPE_TIME = "time"
local INDEX_RECIPE_PRODUCT_NAME = "productName"
local INDEX_RECIPE_PRODUCT_AMOUNT = "productAmount"
local INDEX_RECIPE_INGREDIENTS = "ingredientsTable"

local INDEX_OPTION_ID = "name" -- this is for backwards compatibility, it's actually an ID
local INDEX_OPTION_AMOUNT = "amount"

local VIEW_TABLE_BUILDING_SINGLE_ICON_SIZE = "huge"
local VIEW_TABLE_BUILDING_MULTIPLE_ICON_SIZE = "large"
local VIEW_TABLE_INGREDIENT_ICON_SIZE = "medium"
local VIEW_TABLE_PRODUCT_ICON_SIZE = "huge"

local VIEW_CLASS_TABLE_INGREDIENTS_SINGLE_ICON = 'class=ats-single-ingredient-icon'
local VIEW_CLASS_TABLE_INGREDIENTS_SWAPPABLE_ICON = 'class=ats-swappable-ingredient-icon'

local VIEW_GRADES = {
	[0] = '0Star',
	[1] = '1Star',
	[2] = '2Star',
	[3] = '3Star',
}

--- Transform the grade only when using the value as an index, to help it sort better whenever possible.
local STORE_GRADES = {
	[0] = 1,
	[1] = 2,
	[2] = 3,
	[3] = 4,
}




local MAX_INGREDIENTS = 3
local INDEX_OPTION_STACK_SIZE = "stackSize"
local INDEX_OPTION_GOOD_NAME = "name"
local INDEX_OPTION_GOOD_ICON = "icon"

local TABLE_HEADER_BOTH = "Product or Service"
local TABLE_HEADER_PRODUCT = "Product"
local TABLE_HEADER_SERVICE = "Service"

--endregion



--region Private member variables

--endregion



--region Private methods

---
--- Get the ingredients from the specified recipe and return a list of
--- the names and icons of the ingredients for that recipe.
---
---@param recipeID string the ID of the recipe from which to extract ingredients
---@return table of ingredients and options (stack size, name, and icon)
local function extractIngredientsNamesAndIcons(recipeID)

	local ingredientsList = {}
	-- Need to convert IDs to names and icons and keep stack size.
	for i = 1, MAX_INGREDIENTS do
		for j = 1, WorkshopsRecipesData.getRecipeNumberOfOptionsByID(recipeID, i) do

			if not ingredientsList[i] then
				ingredientsList[i] = {}
			end

			local goodID, stackSize = WorkshopsRecipesData.getRecipeOptionByID(recipeID, i, j)
			ingredientsList[i][j] = {}
			ingredientsList[i][j][INDEX_OPTION_STACK_SIZE] = stackSize
			ingredientsList[i][j][INDEX_OPTION_GOOD_NAME] = GoodsData.getGoodNameByID(goodID)
			ingredientsList[i][j][INDEX_OPTION_GOOD_ICON] = GoodsData.getGoodIconByID(goodID)
		end
	end

	return ingredientsList
end



---
--- Get the ingredients from the specified recipe and return a list of
--- the names and icons of the ingredients for that recipe.
---
---@param recipeID string the ID of the recipe from which to extract ingredients
---@return table of ingredients and options (stack size, name, and icon)
local function extractServiceGoodsNamesAndIcons(recipeID)

	local ingredientsList = {}
	-- Need to convert IDs to names and icons and keep stack size.
	for i = 1, ServicesRecipesData.getRecipeNumberOfServiceGoodsByID(recipeID) do

		ingredientsList[i] = {}

		-- Need to structure this the same way as when there are several
		-- ingredient option groups. But for services there's just one.
		local j = 1
		ingredientsList[i][j] = {}
		local goodID, stackSize = ServicesRecipesData.getRecipeOptionByID(recipeID, i)
		ingredientsList[i][j][INDEX_OPTION_STACK_SIZE] = stackSize
		ingredientsList[i][j][INDEX_OPTION_GOOD_NAME] = GoodsData.getGoodNameByID(goodID)
		ingredientsList[i][j][INDEX_OPTION_GOOD_ICON] = GoodsData.getGoodIconByID(goodID)
	end

	return ingredientsList
end



---
--- Main engine that builds the view based on the list of buildings from
--- workshops, which are slightly different than other building lists.
---
---@param recipeListFromWorkshops table the list of recipes to render
---@param displayOverride string to control if the display isn't default
---@param numIngredients number of ingredient columns to display
---@param requiredBuilding string the name of the required building
local function buildRowsForWorkshops(recipeListFromWorkshops, displayOverride, numIngredients, requiredBuilding)

	-- Build the rows of the middle of the view. If it's nil and there are no
	-- recipes, skip it by getting ipairs on an empty table.
	for _, recipeID in ipairs(recipeListFromWorkshops or {}) do

		local gradeStars = WorkshopsRecipesData.getRecipeGradeByID(recipeID)
		local productionTime = WorkshopsRecipesData.getRecipeProductionTimeByID(recipeID)
		local productID, productStackSize = WorkshopsRecipesData.getRecipeProductByID(recipeID)

		local ingredientsList = extractIngredientsNamesAndIcons(recipeID)

		local productName = GoodsData.getGoodNameByID(productID)
		local productIcon = GoodsData.getGoodIconByID(productID)

		local buildingListFromWorkshops = WorkshopsData.getWorkshopNamesWithRecipeID(recipeID)

		for _, buildingName in ipairs(buildingListFromWorkshops) do

			-- If we're screening for a building (not nil), then we have
			-- to match the current building
			if not requiredBuilding or buildingName == requiredBuilding then

				local buildingIcon = WorkshopsData.getWorkshopIcon(buildingName)

				RecipeView.addRowForRecipe(displayOverride, numIngredients,
						buildingName, buildingIcon,
						gradeStars, productionTime, ingredientsList,
						productStackSize, productName, productIcon, requiredBuilding)

			end
		end

	end
end



---
--- Main engine that builds the view based on the list of buildings from
--- services, which are slightly different than other building lists.
---
---@param recipeListFromServices table the list of recipes to render
---@param displayOverride string to control if the display isn't default
---@param numIngredients number of ingredient columns to display
---@param requiredBuilding string the name of the required building
local function buildRowsForServices(recipeListFromServices, displayOverride, numIngredients, requiredBuilding)

	-- Same. If it's nil and there are no recipes, skip it by getting ipairs
	-- on an empty table.
	for _, recipeID in ipairs(recipeListFromServices or {}) do

		local gradeStars = ServicesRecipesData.getRecipeGradeByID(recipeID)
		local serviceName = ServicesRecipesData.getRecipeServiceNameByID(recipeID)
		local serviceIcon = ServicesRecipesData.getRecipeServiceIconByID(recipeID)

		local ingredientsList = extractServiceGoodsNamesAndIcons(recipeID)

		local buildingListFromInstitutions = InstitutionsData.getInstitutionNamesWithRecipeID(recipeID)

		local productionTime -- = nil
		local resultStackSize -- = nil

		for _, buildingName in ipairs(buildingListFromInstitutions) do

			-- If we're screening for a building (not nil), then we have
			-- to match the current building
			if not requiredBuilding or buildingName == requiredBuilding then

				local buildingIcon = InstitutionsData.getInstitutionIcon(buildingName)

				RecipeView.addRowForRecipe(displayOverride, numIngredients,
						buildingName, buildingIcon,
						gradeStars, productionTime, ingredientsList,
						resultStackSize, serviceName, serviceIcon, requiredBuilding)
			end
		end

	end
end



---
--- Main engine that builds the view based on the list of buildings from
--- farms, which are slightly different than other building lists.
---
---@param buildingListFromFarms table the list of recipes to render
---@param displayOverride string to control if the display isn't default
---@param numIngredients number of ingredient columns to display
---@param productID string of the product to render
---@param requiredBuilding string the name of the required building
local function buildRowsForFarms(buildingListFromFarms, displayOverride, numIngredients, productID, requiredBuilding)

	for _, buildingName in ipairs(buildingListFromFarms or {}) do

		-- If we're screening for a building (not nil), then we have to match
		-- the current building
		if not requiredBuilding or buildingName == requiredBuilding then

			for i = 1, FarmsData.getFarmNumberOfRecipes(buildingName) do

				local goodID, stackSize = FarmsData.getFarmRecipeProduct(buildingName, i)

				-- If we're screening for a productID (not nil), then we have
				-- to match the current goodID
				if not productID or goodID == productID then

					local buildingIcon = FarmsData.getFarmIcon(buildingName)
					local gradeStars, plantingTime, harvestingTime = FarmsData.getFarmRecipeStats(buildingName, i)
					local ingredientsList = {}
					local productName = GoodsData.getGoodNameByID(goodID)
					local productIcon = GoodsData.getGoodIconByID(goodID)

					RecipeView.addRowForRecipe(displayOverride, numIngredients,
							buildingName, buildingIcon,
							gradeStars, (plantingTime + harvestingTime),
							ingredientsList, stackSize,
							productName, productIcon, requiredBuilding)
				end

			end
		end

	end
end



---
--- Main engine that builds the view based on the list of buildings from
--- camps, which are slightly different than other building lists.
---
---@param buildingListFromCamps table the list of recipes to render
---@param displayOverride string to control if the display isn't default
---@param numIngredients number of ingredient columns to display
---@param productID string of the product to render
---@param requiredBuilding string the name of the required building
local function buildRowsForCamps(buildingListFromCamps, displayOverride, numIngredients, productID, requiredBuilding)

	for _, buildingName in ipairs(buildingListFromCamps or {}) do

		-- If we're screening for a building (not nil), then we have to match
		-- the current building
		if not requiredBuilding or buildingName == requiredBuilding then

			for i = 1, CampsData.getCampNumberOfRecipes(buildingName) do

				local goodID, stackSize = CampsData.getCampRecipeProduct(buildingName, i)

				-- If we're screening for a productID (not nil), then we have to
				-- match the current goodID
				if not productID or goodID == productID then

					local buildingIcon = CampsData.getCampIcon(buildingName)
					local gradeStars, gatheringTime = CampsData.getCampRecipeStats(buildingName, i)
					local ingredientsList = {}
					local productName = GoodsData.getGoodNameByID(goodID)
					local productIcon = GoodsData.getGoodIconByID(goodID)

					RecipeView.addRowForRecipe(displayOverride, numIngredients,
							buildingName, buildingIcon,
							gradeStars, gatheringTime,
							ingredientsList, stackSize,
							productName, productIcon, requiredBuilding)
				end

			end
		end

	end
end



---
--- Main engine that builds the view based on the list of recipes from
--- collectors, , which are slightly different than other building lists.
---
---@param buildingListFromCollectors table the list of recipes to render
---@param displayOverride string to control if the display isn't default
---@param numIngredients number of ingredient columns to display
---@param productID string of the product to render
---@param requiredBuilding string the name of the required building
local function buildRowsForCollectors(buildingListFromCollectors, displayOverride, numIngredients, productID, requiredBuilding)

	for _, buildingName in ipairs(buildingListFromCollectors or {}) do

		-- If we're screening for a building (not nil), then we have to match
		-- the current building
		if not requiredBuilding or buildingName == requiredBuilding then

			for i = 1, CollectorsData.getCollectorNumberOfRecipes(buildingName) do

				local goodID, stackSize = CollectorsData.getCollectorRecipeProduct(buildingName, i)

				-- If we're screening for a productID (not nil), then we have to
				-- match the current goodID
				if not productID or goodID == productID then

					local buildingIcon = CollectorsData.getCollectorIcon(buildingName)
					local gradeStars, productionTime = CollectorsData.getCollectorRecipeStats(buildingName, i)
					local ingredientsList = {}
					local productName = GoodsData.getGoodNameByID(goodID)
					local productIcon = GoodsData.getGoodIconByID(goodID)

					RecipeView.addRowForRecipe(displayOverride, numIngredients,
							buildingName, buildingIcon,
							gradeStars, productionTime,
							ingredientsList, stackSize,
							productName, productIcon, requiredBuilding)
				end

			end
		end

	end
end



---
--- Takes two lists and returns the intersection of the two. Slightly more
--- efficient if the second list is smaller than the first.
---
---@param list1 table a flat table (list of values)
---@param list2 table another flat table
---@return table a similarly flat table consisting only of the values that are in both provided lists
local function findIntersection(list1, list2)

	local intersection = {}

	-- Create a new mapping to efficiently check for existence in list2 by
	-- checking the same ID in the next loop.
	local setList2 = {}
	for _, value in ipairs(list2 or {}) do
		setList2[value] = true
	end

	-- Check for intersection and populate the result table.
	for _, valueToFind in ipairs(list1 or {}) do
		if setList2[valueToFind] then
			table.insert(intersection, valueToFind)
		end
	end

	if #intersection == 0 then
		return nil
	end

	return intersection
end

--endregion



--region Public methods

---
--- Compiles the data, organizes it appropriately and in a way that protects
--- the view from needing the details, and sends it to the view for rendering
--- before returning it to the template.
---
---@param ingredientName string the ingredient to base the table on
---@param buildingName string the building to base the table on
---@param displayOverride string to control the display if not default
local function renderWithIngredientAndBuilding(ingredientName, buildingName, displayOverride)

	-- Service and workshops recipes have ingredients. Camps and farms do not.
	local ingredientID = GoodsData.getGoodID(ingredientName)
	if not ingredientID then
		return "No ingredient found with that name: " .. ingredientName .. "."
	end
	local recipeListFromWorkshopsRecipes = WorkshopsRecipesData.getAllRecipeIDsWithIngredientID(ingredientID)
	local recipeListFromWorkshops = WorkshopsData.getAllWorkshopRecipes(buildingName)
	recipeListFromWorkshops = findIntersection(recipeListFromWorkshopsRecipes, recipeListFromWorkshops)

	local recipeListFromServicesRecipes = ServicesRecipesData.getAllRecipeIDsWithServiceGoodID(ingredientID)
	local recipeListFromServices = InstitutionsData.getAllInstitutionRecipes(buildingName)
	recipeListFromServices = findIntersection(recipeListFromServicesRecipes, recipeListFromServices)

	-- Find out what the largest number of ingredients in this table is. Need
	-- this before we start building any of the beginning and the middle so
	-- that they are all the same.
	local numIngredients  = 1
	if recipeListFromWorkshops then
		for _, recipeID in ipairs(recipeListFromWorkshops) do
			local thisNum = WorkshopsRecipesData.getRecipeNumberOfIngredientsByID(recipeID)
			if numIngredients < thisNum	then
				numIngredients = thisNum
			end
		end
	end
	if recipeListFromServices then
		for _, recipeID in ipairs(recipeListFromServices) do
			local thisNum = ServicesRecipesData.getRecipeNumberOfServiceGoodsByID(recipeID)
			if numIngredients < thisNum	then
				numIngredients = thisNum
			end
		end
	end

	-- Count the number of recipes in both the Workshops and Services.
	local numRecipes = 0
	local header -- = nil
	if recipeListFromWorkshops ~= nil then
		numRecipes = numRecipes + #recipeListFromWorkshops
		header = TABLE_HEADER_PRODUCT
	end
	if recipeListFromServices ~= nil then
		if numRecipes > 0 then
			header = TABLE_HEADER_BOTH
		else
			header = TABLE_HEADER_SERVICE
		end
		numRecipes = numRecipes + #recipeListFromServices
	end

	if numRecipes == 0 then
		return "No recipes found for ingredient " .. ingredientName .. " in the building " .. buildingName .. "."
	end

	RecipeView.startViewForIngredientAndBuilding(ingredientName, buildingName, displayOverride, numRecipes, numIngredients, header)

	buildRowsForWorkshops(recipeListFromWorkshops, displayOverride, numIngredients, buildingName)

	buildRowsForServices(recipeListFromServices, displayOverride, numIngredients, buildingName)

	return RecipeView.endView(displayOverride)
end



---
--- Compiles the data, organizes it appropriately and in a way that protects
--- the view from needing the details, and sends it to the view for rendering
--- before returning it to the template.
---
---@param ingredientName string the ingredient to base the table on
---@param displayOverride string to control the display if not default
local function renderWithIngredient(ingredientName, displayOverride)

	-- Service and workshops recipes have ingredients. Camps and farms do not.
	local ingredientID = GoodsData.getGoodID(ingredientName)
	if not ingredientID then
		return "No ingredient found with that name: " .. ingredientName .. "."
	end
	local recipeListFromWorkshops = WorkshopsRecipesData.getAllRecipeIDsWithIngredientID(ingredientID)
	local recipeListFromServices = ServicesRecipesData.getAllRecipeIDsWithServiceGoodID(ingredientID)

	-- Find out what the largest number of ingredients in this table is. Need
	-- this before we start building any of the beginning and the middle so
	-- that they are all the same.
	local numIngredients  = 1
	if recipeListFromWorkshops then
		for _, recipeID in ipairs(recipeListFromWorkshops) do
			local thisNum = WorkshopsRecipesData.getRecipeNumberOfIngredientsByID(recipeID)
			if numIngredients < thisNum	then
				numIngredients = thisNum
			end
		end
	end
	if recipeListFromServices then
		for _, recipeID in ipairs(recipeListFromServices) do
			local thisNum = ServicesRecipesData.getRecipeNumberOfServiceGoodsByID(recipeID)
			if numIngredients < thisNum	then
				numIngredients = thisNum
			end
		end
	end

	-- Count the number of recipes in both the Workshops and Services.
	local numRecipes = 0
	local header -- = nil
	if recipeListFromWorkshops ~= nil then
		numRecipes = numRecipes + #recipeListFromWorkshops
		header = TABLE_HEADER_PRODUCT
	end
	if recipeListFromServices ~= nil then
		if numRecipes > 0 then
			header = TABLE_HEADER_BOTH
		else
			header = TABLE_HEADER_SERVICE
		end
		numRecipes = numRecipes + #recipeListFromServices
	end

	if numRecipes == 0 then
		return "No recipes found for ingredient: " .. ingredientName .. "."
	end

	RecipeView.startViewForIngredient(ingredientName, displayOverride, numRecipes, numIngredients, header)

	buildRowsForWorkshops(recipeListFromWorkshops, displayOverride, numIngredients)

	buildRowsForServices(recipeListFromServices, displayOverride, numIngredients)

	return RecipeView.endView(displayOverride)
end



---
--- Compiles the data, organizes it appropriately and in a way that protects
--- the view from needing the details, and sends it to the view for rendering
--- before returning it to the template.
---
---@param productName string the product to base the table on
---@param buildingName string the building to base the table on
---@param displayOverride string to control the display if not default
local function renderWithProductAndBuilding(productName, buildingName, displayOverride)

	-- Get lists from all buildings
	local productID = GoodsData.getGoodID(productName)

	local recipeListFromWorkshopsRecipes
	if productID then
		recipeListFromWorkshopsRecipes = WorkshopsRecipesData.getAllRecipeIDsForProductID(productID)
	end
	local recipeListFromWorkshops = WorkshopsData.getAllWorkshopRecipes(buildingName)
	recipeListFromWorkshops = findIntersection(recipeListFromWorkshopsRecipes, recipeListFromWorkshops)

	-- It could be the name of a service instead of the name of a product.
	local recipeListFromServicesRecipes = ServicesRecipesData.getAllRecipeIDsForServiceName(productName)
	local recipeListFromServices = InstitutionsData.getAllInstitutionRecipes(buildingName)
	recipeListFromServices = findIntersection(recipeListFromServicesRecipes, recipeListFromServices)

	-- These result in a list of buildings, not recipes.
	local buildingListFromCamps
	local buildingListFromFarms
	local buildingListFromCollectors
	if productID then
		buildingListFromCamps = CampsData.getCampNamesWithRecipeProductID(productID)
		buildingListFromFarms = FarmsData.getFarmNamesWithRecipeProductID(productID)
		buildingListFromCollectors = CollectorsData.getCollectorNamesWithRecipeProductID(productID)
	end
	buildingListFromCamps = findIntersection(buildingListFromCamps, {buildingName} )
	buildingListFromFarms = findIntersection(buildingListFromFarms, {buildingName})
	buildingListFromCollectors = findIntersection(buildingListFromCollectors, {buildingName})

	-- Find out what the largest number of ingredients in this table is. Need
	-- this before we start building any of the beginning and the middle so
	-- that they are all the same.
	local numIngredients  = 1
	if recipeListFromWorkshops then
		for _, recipeID in ipairs(recipeListFromWorkshops) do
			local thisNum = WorkshopsRecipesData.getRecipeNumberOfIngredientsByID(recipeID)
			if numIngredients < thisNum	then
				numIngredients = thisNum
			end
		end
	end
	if recipeListFromServices then
		for _, recipeID in ipairs(recipeListFromServices) do
			local thisNum = ServicesRecipesData.getRecipeNumberOfServiceGoodsByID(recipeID)
			if numIngredients < thisNum	then
				numIngredients = thisNum
			end
		end
	end

	-- Count the number of recipes in all the lists
	local numRecipes = 0
	local header -- = nil
	if recipeListFromWorkshops ~= nil then
		numRecipes = numRecipes + #recipeListFromWorkshops
		header = TABLE_HEADER_PRODUCT
	end
	if buildingListFromCamps ~= nil then
		numRecipes = numRecipes + #buildingListFromCamps
		header = TABLE_HEADER_PRODUCT
	end
	if buildingListFromFarms ~= nil then
		numRecipes = numRecipes + #buildingListFromFarms
		header = TABLE_HEADER_PRODUCT
	end
	if buildingListFromCollectors ~= nil then
		numRecipes = numRecipes + #buildingListFromCollectors
		header = TABLE_HEADER_PRODUCT
	end
	if recipeListFromServices ~= nil then
		if numRecipes > 0 then
			header = TABLE_HEADER_BOTH
		else
			header = TABLE_HEADER_SERVICE
		end
		numRecipes = numRecipes + #recipeListFromServices
	end

	if numRecipes == 0 then
		return "No recipes found for product " .. productName .. " at the building " .. buildingName .. "."
	end

	RecipeView.startViewForProductAndBuilding(productName, buildingName, displayOverride, numRecipes, numIngredients, header)

	buildRowsForWorkshops(recipeListFromWorkshops, displayOverride, numIngredients, buildingName)

	buildRowsForServices(recipeListFromServices, displayOverride, numIngredients, buildingName)

	buildRowsForFarms(buildingListFromFarms, displayOverride, numIngredients, productID, buildingName)

	buildRowsForCamps(buildingListFromCamps, displayOverride, numIngredients, productID, buildingName)

	buildRowsForCollectors(buildingListFromCollectors, displayOverride, numIngredients, productID, buildingName)

	return RecipeView.endView(displayOverride)
end



---
--- Compiles the data, organizes it appropriately and in a way that protects
--- the view from needing the details, and sends it to the view for rendering
--- before returning it to the template.
---
---@param productName string the product to base the table on
---@param displayOverride string to control the display if not default
local function renderWithProduct(productName, displayOverride)

	-- Get lists from all buildings
	local productID = GoodsData.getGoodID(productName)

	local recipeListFromWorkshops
	if productID then
		recipeListFromWorkshops = WorkshopsRecipesData.getAllRecipeIDsForProductID(productID)
	end
	-- It could be the name of a service instead of the name of a product.
	local recipeListFromServices = ServicesRecipesData.getAllRecipeIDsForServiceName(productName)

	-- These result in a list of buildings, not recipes.
	local buildingListFromCamps
	local buildingListFromFarms
	local buildingListFromCollectors
	if productID then
		buildingListFromCamps = CampsData.getCampNamesWithRecipeProductID(productID)
		buildingListFromFarms = FarmsData.getFarmNamesWithRecipeProductID(productID)
		buildingListFromCollectors = CollectorsData.getCollectorNamesWithRecipeProductID(productID)
	end


	-- Find out what the largest number of ingredients in this table is. Need
	-- this before we start building any of the beginning and the middle so
	-- that they are all the same.
	local numIngredients  = 1
	if recipeListFromWorkshops then
		for _, recipeID in ipairs(recipeListFromWorkshops) do
			local thisNum = WorkshopsRecipesData.getRecipeNumberOfIngredientsByID(recipeID)
			if numIngredients < thisNum	then
				numIngredients = thisNum
			end
		end
	end
	if recipeListFromServices then
		for _, recipeID in ipairs(recipeListFromServices) do
			local thisNum = ServicesRecipesData.getRecipeNumberOfServiceGoodsByID(recipeID)
			if numIngredients < thisNum	then
				numIngredients = thisNum
			end
		end
	end

	-- Count the number of recipes in all the lists
	local numRecipes = 0
	local header -- = nil
	if recipeListFromWorkshops ~= nil then
		numRecipes = numRecipes + #recipeListFromWorkshops
		header = TABLE_HEADER_PRODUCT
	end
	if buildingListFromCamps ~= nil then
		numRecipes = numRecipes + #buildingListFromCamps
		header = TABLE_HEADER_PRODUCT
	end
	if buildingListFromFarms ~= nil then
		numRecipes = numRecipes + #buildingListFromFarms
		header = TABLE_HEADER_PRODUCT
	end
	if buildingListFromCollectors ~= nil then
		numRecipes = numRecipes + #buildingListFromCollectors
		header = TABLE_HEADER_PRODUCT
	end
	if recipeListFromServices ~= nil then
		if numRecipes > 0 then
			header = TABLE_HEADER_BOTH
		else
			header = TABLE_HEADER_SERVICE
		end
		numRecipes = numRecipes + #recipeListFromServices
	end

	if numRecipes == 0 then
		return "No recipes found for product: " .. productName .. "."
	end

	RecipeView.startViewForProduct(productName, displayOverride, numRecipes, numIngredients, header)

	buildRowsForWorkshops(recipeListFromWorkshops, displayOverride, numIngredients)

	buildRowsForServices(recipeListFromServices, displayOverride, numIngredients)

	buildRowsForFarms(buildingListFromFarms, displayOverride, numIngredients, productID)

	buildRowsForCamps(buildingListFromCamps, displayOverride, numIngredients, productID)

	buildRowsForCollectors(buildingListFromCollectors, displayOverride, numIngredients, productID)

	return RecipeView.endView(displayOverride)
end



---
--- Compiles the data, organizes it appropriately and in a way that protects
--- the view from needing the details, and sends it to the view for rendering
--- before returning it to the template.
---
---@param buildingName string the building to base the table on
---@param displayOverride string to control the display if not default
local function renderWithBuilding(buildingName, displayOverride)

	local recipeListFromWorkshops = WorkshopsData.getAllWorkshopRecipes(buildingName)
	local recipeListFromServices = InstitutionsData.getAllInstitutionRecipes(buildingName)

	-- For camps, farms, and collectors where recipes are stored in the
	-- building data file, just check to make sure the building has recipes
	-- from that data. If so, that's all we need to make the building list.
	local buildingListFromCamps = {}
	if CampsData.getCampNumberOfRecipes(buildingName) > 0 then
		buildingListFromCamps = { buildingName }
	end
	local buildingListFromFarms = {}
	if FarmsData.getFarmNumberOfRecipes(buildingName) > 0 then
		buildingListFromFarms = { buildingName }
	end
	local buildingListFromCollectors = {}
	if CollectorsData.getCollectorNumberOfRecipes(buildingName) > 0 then
		buildingListFromCollectors = { buildingName }
	end

	-- Find out what the largest number of ingredients in this table is. Need
	-- this before we start building any of the beginning and the middle so
	-- that they are all the same.
	local numIngredients = 1
	if recipeListFromWorkshops then
		for _, recipeID in ipairs(recipeListFromWorkshops) do
			local thisNum = WorkshopsRecipesData.getRecipeNumberOfIngredientsByID(recipeID)
			if numIngredients < thisNum	then
				numIngredients = thisNum
			end
		end
	end
	if recipeListFromServices then
		for _, recipeID in ipairs(recipeListFromServices) do
			local thisNum = ServicesRecipesData.getRecipeNumberOfServiceGoodsByID(recipeID)
			if numIngredients < thisNum	then
				numIngredients = thisNum
			end
		end
	end

	-- Count the number of recipes in all the lists
	local numRecipes = 0
	local header -- = nil
	if recipeListFromWorkshops ~= nil then
		numRecipes = numRecipes + #recipeListFromWorkshops
		header = TABLE_HEADER_PRODUCT
	end
	if buildingListFromCamps ~= nil then
		numRecipes = numRecipes + CampsData.getCampNumberOfRecipes(buildingName)
		header = TABLE_HEADER_PRODUCT
	end
	if buildingListFromFarms ~= nil then
		numRecipes = numRecipes + FarmsData.getFarmNumberOfRecipes(buildingName)
		header = TABLE_HEADER_PRODUCT
	end
	if buildingListFromCollectors ~= nil then
		numRecipes = numRecipes + CollectorsData.getCollectorNumberOfRecipes(buildingName)
		header = TABLE_HEADER_PRODUCT
	end
	if recipeListFromServices ~= nil then
		if numRecipes > 0 then
			header = TABLE_HEADER_BOTH
		else
			header = TABLE_HEADER_SERVICE
		end
		numRecipes = numRecipes + #recipeListFromServices
	end

	if numRecipes == 0 then
		return "No recipes found at building: " .. buildingName .. "."
	end

	RecipeView.startViewForBuilding(buildingName, displayOverride, numRecipes, numIngredients, header)

	buildRowsForWorkshops(recipeListFromWorkshops, displayOverride, numIngredients, buildingName)

	buildRowsForServices(recipeListFromServices, displayOverride, numIngredients, buildingName)

	buildRowsForFarms(buildingListFromFarms, displayOverride, numIngredients, nil, buildingName)

	buildRowsForCamps(buildingListFromCamps, displayOverride, numIngredients, nil, buildingName)

	buildRowsForCollectors(buildingListFromCollectors, displayOverride, numIngredients, nil, buildingName)

	return RecipeView.endView(displayOverride)
end

--endregion











--region Private methods

---getFlatRecipeValues
---Extracts a handful of values from the provided recipe pair.
---
---@param recipeData table pair of recipe data retrieved from a data model
---@return string, number, number, string, number building ID, efficiency grade, production time, product ID, and product amount (respectively)
local function getFlatRecipeValues(recipeData)
	local buildingID = BaseDataModel.getRecipeBuildingID(recipeData)
	local grade = BaseDataModel.getRecipeGrade(recipeData)
	local time = BaseDataModel.getRecipeTime(recipeData)
	local productID = BaseDataModel.getRecipeProductID(recipeData)
	local productAmount = BaseDataModel.getRecipeProductAmount(recipeData)

	return buildingID, grade, time, productID, productAmount
end

---buildingIngredientsTable
---Extracts values from the provided recipe pair and builds an ingredients table for use in a Recipe object.
---
---@param recipeData table pair of recipe data retrieved from a data model
---@return table nested ingredients, options (ID and amount)
local function buildingIngredientsTable(recipeData)

	local ingredientsTable = {}
	for i = 1, BaseDataModel.getRecipeNumIngredientSlots(recipeData) do

		if not ingredientsTable[i] then
			ingredientsTable[i] = {}
		end
		for j = 1, BaseDataModel.getRecipeIngredientNumOptions(recipeData, i) do
			ingredientsTable[i][j] = {
				[INDEX_OPTION_ID] = BaseDataModel.getRecipeIngredientOptionIDAt(recipeData, i, j),
				[INDEX_OPTION_AMOUNT] = BaseDataModel.getRecipeIngredientOptionAmountAt(recipeData, i, j),
			}
		end
	end

	return ingredientsTable
end

---resolveBuildingName
---Quickly runs through the data models and attempts with each to resolve the provided ID into a name.
---
---@param buildingID string the ID
---@return string the name, or nil if not found
local function resolveBuildingName(buildingID)
	for _, dataModel in ipairs(DataModelsArray) do
		local buildingName = dataModel:getName(buildingID)
		if buildingName then
			return buildingName
		end
	end
	return nil
end

---resolveBuildingID
---Quickly runs through the data models and attempts with each to resolve the provided name into an ID.
---
---@param buildingName string the display name
---@return string the building's ID
local function resolveBuildingID(buildingName)
	for _, dataModel in ipairs(DataModelsArray) do
		local id = dataModel:getID(buildingName)
		if id then
			return id
		end
	end
	return nil
end


---resolveBuildingIcon
---Quickly runs through the data models and attempts with each to resolve the provided name into an icon.
---
---@param buildingName string the display name
---@return string the filename for its icon
local function resolveBuildingIcon(buildingName)
	for _, dataModel in ipairs(DataModelsArray) do
		local id = dataModel:getID(buildingName)
		if id then
			return dataModel:getIcon(id)
		end
	end
	return nil
end

---getRawRecipes
---Queries the data models with the supplied parameters to construct an array of Recipe objects storing the recipes found in the data model.
---
---Benchmarking: ~0.0003 seconds
---
---@param DataModel table a require'd data model that implements the recipe query interface, passed in for code reuse
---@param productID string the ID of the product, or nil if any
---@param buildingID string the ID of the building, or nil if any
---@param ingredientID string the ID of an ingredient, or nil if any
---@return table array of pairs of buildingID and recipe data
local function getRawRecipes(DataModel, productID, buildingID, ingredientID)

	local rawRecipeList = {}
	if productID and buildingID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereProductIDAndBuildingID(productID, buildingID)
	elseif productID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereProductID(productID)
	elseif ingredientID and buildingID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereIngredientIDAndBuildingID(ingredientID, buildingID)
	elseif ingredientID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereIngredientID(ingredientID)
	elseif buildingID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereBuildingID(buildingID)
	else
		error("You must specify a product, building, or ingredient. Please see the template documentation for how to use the parameters")
	end

	return rawRecipeList
end

---compileRecipeLists
---Adds the second list to the first, but restructures into Recipe objects along the way.
---
---@param recipeObjectTable table 3-factor array of Recipe objects, by product, grade, amount
---@param rawRecipeTable table list of recipe pairs, as gotten from a data model
---@return table the same recipeObjectTable, but with new and updated entries
local function compileRecipeLists(recipeObjectTable, rawRecipeTable)

	for _, pair in ipairs(rawRecipeTable) do

		local buildingID, grade, time, productID, productAmount = getFlatRecipeValues(pair)
		local ingredientsTable = buildingIngredientsTable(pair)

		local buildingName = resolveBuildingName(buildingID)

		-- Services identify their need by name, but goods to not. If it's a service, this is a simple renaming.
		local productName = productID
		if not BaseDataModel.isRecipeProvidingService(pair) then
			productName = GoodsData.getName(productID)
		end


		-- Now that we have everything extracted from rawRecipeTable for this pair, load it into recipeObjectTable, whether as a new Recipe object or adding a building to an existing Recipe object if one already exists. Recipes are uniquely identified by the 3-way combination of product, grade, and product amount.
		if not recipeObjectTable[productName] then
		recipeObjectTable[productName] = {}
		end
		-- Transform the grade values to store them in a natural order.
		if not recipeObjectTable[productName][STORE_GRADES[grade]] then
		recipeObjectTable[productName][STORE_GRADES[grade]] = {}
		end
		if not recipeObjectTable[productName][STORE_GRADES[grade]][productAmount] then
		-- Create a new Recipe object at this place in the table.
		recipeObjectTable[productName][STORE_GRADES[grade]][productAmount] = RecipeController.Recipe.new( { buildingName }, grade, time, productID, productAmount, ingredientsTable)
		else
		-- Add the building to the existing Recipe object at this place in the table.
		recipeObjectTable[productName][STORE_GRADES[grade]][productAmount]:addBuilding(buildingName)
			end
		end

	return recipeObjectTable
end

---getRecipesFromAllDataModels
---Goes through all data models and compiles the results into a single 3-factor table of Recipe objects, [product][grade][amount]. This table will be sparse, and note sometimes the grade is harder to spot in the console if it starts at 1 and is followed by 2 (because the console interprets it as an un-keyed array.
---
---For example, finding the recipe for Biscuits in the Field Kitchen: recipeObjectArray["Biscuits"][0][10]
---
---@param requiredProduct string the name of the product, or nil if any
---@param requiredBuilding string the name of the building, or nil if any
---@param requiredIngredient string the name of the ingredient, or nil if any
---@return table a 3-factor compiled table of Recipe objects, [product][grade][amount]
local function getRecipesFromAllDataModels(requiredProduct, requiredBuilding, requiredIngredient)

	--Resolve names to IDs, start them all as nil as wildcards.
	local productID
	local buildingID
	local ingredientID

	if requiredProduct then
		productID = GoodsData.getGoodID(requiredProduct)
		-- If it's not a good, then it's a service, which has ID == name
		if not productID then
			productID = requiredProduct
		end
	end

	if requiredBuilding then
		buildingID = resolveBuildingID(requiredBuilding)
	end

	if requiredIngredient then
		ingredientID = GoodsData.getGoodID(requiredIngredient)
	end

	recipeObjectArray = {}
	for _, dataModel in ipairs(DataModelsArray) do
		local newRecipeList = getRawRecipes(dataModel, productID, buildingID, ingredientID)
		recipeObjectArray = compileRecipeLists(recipeObjectArray, newRecipeList)
	end

	return recipeObjectArray
end

---countMaxIngredients
---Scans through the provided table of Recipe objects to count them.
---
---@param recipeList table 3-factor table of Recipe objects
---@return number of recipe objects
local function countRecipes(recipeList)

	local count = 0
	for _, product in pairs(recipeList) do
		for _, grade in pairs(product) do
			for _, recipe in pairs(grade) do
				count = count + 1
			end
		end
	end
	return count
end

---countMaxIngredients
---Scans through the provided table of Recipe objects to find the recipe with the maximum number of ingredients slots (not options, whole slots of options).
---
---@param recipeList table 3-factor table of Recipe objects
---@return number of ingredients slots required to represent them all
local function countMaxIngredients(recipeList)

	local max = 0
	for _, product in pairs(recipeList) do
		for _, grade in pairs(product) do
			for _, recipe in pairs(grade) do
				local num = recipe:getNumIngredients()
				if max < num then
					max = num
				end
			end
		end
	end
	return max
end

---calculateCaption
---Simple cascading of rewriting the author requirements into the caption and how many were returned.
---
---@param requiredProduct string name of product, or nil if any
---@param requiredBuilding string name of building, or nil if any
---@param requiredIngredient string name of ingredient, or nil if any
---@param numRecipes number of recipes
---@return string the caption
local function calculateCaption(requiredProduct, requiredBuilding, requiredIngredient, numRecipes)

	local caption = numRecipes .. " recipes"

	if requiredProduct then
		caption = caption .. " for " .. requiredProduct

		if requiredBuilding then
			return caption .. " in the " .. requiredBuilding .. "."
		end
	end

	if requiredIngredient then
		caption = caption .. " using " .. requiredIngredient

		if requiredBuilding then
			return caption .. " in the " .. requiredBuilding .. "."
		end
	end

	return caption  .. "."
end



---buildMiddle
---Calls the view to render table rows for each Recipe object and links to buildings and resources within the table rows.
---
---Benchmarking: ~0.005 seconds
---
---@param frame table MediaWiki template context
---@param recipeList table 3-factor list of Recipe objects, by [product][grade][amount]
---@param maxIngredients number of ingredients the largest Recipe has
---@param requiredProduct string name of product, or nil if any
---@param requiredBuilding string name of building, or nil if any
---@param _ string name of ingredient, or nil if any
---@return string a long string of wiki markup
local function buildMiddle(frame, recipeList, maxIngredients, requiredProduct, requiredBuilding, _)

	local ret = ""

	for _, recipeProductSubtable in pairs(recipeList) do
		for _, recipeGradeSubtable in pairs(recipeProductSubtable) do
			for _, recipe in pairs(recipeGradeSubtable) do

				local rowArgs = {}
				rowArgs["maxingredients"] = maxIngredients

				local numBuildings = #recipe[INDEX_RECIPE_BUILDINGS_ARRAY]
				rowArgs["building"] = "\n<!-- -->\n{|" --all table markup has to start on its own line; this html comment accomplishes this
				for _, buildingName in ipairs(recipe[INDEX_RECIPE_BUILDINGS_ARRAY]) do
					local buildingLinkArgs = {}
					buildingLinkArgs["name"] = buildingName
					buildingLinkArgs["iconfilename"] = resolveBuildingIcon(buildingName)
					buildingLinkArgs["iconsize"] = (numBuildings < 2) and VIEW_TABLE_BUILDING_SINGLE_ICON_SIZE or VIEW_TABLE_BUILDING_MULTIPLE_ICON_SIZE
					-- Redundant to label the building if the author required it--the image alt-text and link still work with name.
					if requiredBuilding then
						buildingLinkArgs["display"] = "notext"
					end
					rowArgs["building"] = rowArgs["building"] .. "\n|-\n| " .. frame:expandTemplate{
						title = VIEW_BUILDING_LINK,
						args = buildingLinkArgs,
					}
				end
				rowArgs["building"] = rowArgs["building"] .. "\n|}"

				rowArgs["grade"] = frame:expandTemplate{
					title = VIEW_GRADES[recipe[INDEX_RECIPE_GRADE]],
					args = {},
				}
				local minutes = math.floor(recipe[INDEX_RECIPE_TIME] / 60)
				local seconds = recipe[INDEX_RECIPE_TIME] % 60
				rowArgs["grade"] = rowArgs["grade"] .. "<br>" .. string.format("%d:%02d", minutes, seconds)

				-- rowArgs[ingredientI]
				for i, ingredientSlot in ipairs(recipe[INDEX_RECIPE_INGREDIENTS]) do

					local innerTable = "\n<!-- -->\n{|" --all table markup has to start on its own line; this html comment accomplishes this
					local numOptions = #ingredientSlot

					if numOptions > 1 then
						innerTable = innerTable .. VIEW_CLASS_TABLE_INGREDIENTS_SWAPPABLE_ICON
					else
						innerTable = innerTable .. VIEW_CLASS_TABLE_INGREDIENTS_SINGLE_ICON
					end

					for _, option in ipairs(ingredientSlot) do
						local rlArgs = {}
						rlArgs["name"] = GoodsData.getName(option[INDEX_OPTION_ID])
						rlArgs["iconfilename"] = GoodsData.getIcon(option[INDEX_OPTION_ID])
						rlArgs["iconsize"] = VIEW_TABLE_INGREDIENT_ICON_SIZE

						innerTable = innerTable .. "\n|-\n| " .. option[INDEX_OPTION_AMOUNT] .. " |"
								.. "| " .. frame:expandTemplate{
							title = VIEW_RESOURCE_LINK,
							args = rlArgs,
						}
					end
					innerTable = innerTable .. "\n|}\n"
					rowArgs["ingredient" .. i] = innerTable
				end

				local resourceLinkArgs = {}
				resourceLinkArgs["name"] = GoodsData.getName(recipe[INDEX_RECIPE_PRODUCT_NAME])
				resourceLinkArgs["iconfilename"] = GoodsData.getIcon(recipe[INDEX_RECIPE_PRODUCT_NAME])
				resourceLinkArgs["iconsize"] = VIEW_TABLE_PRODUCT_ICON_SIZE
				-- Redundant to label the resource if the author required it--the image alt-text and link still work with name.
				if requiredProduct then
					resourceLinkArgs["display"] = "notext"
				end

				rowArgs["product"] = recipe[INDEX_RECIPE_PRODUCT_AMOUNT] .. "&nbsp;"
						.. frame:expandTemplate{
					title = VIEW_RESOURCE_LINK,
					args = resourceLinkArgs,
				}

				ret = ret .. frame:expandTemplate{
					title = VIEW_TEMPLATE_ROW,
					args = rowArgs,
				}
				ret = ret .. "\n"
			end
		end
	end

	return ret
end

--endregion



--region Public classes

local Recipe = {}

-- This class available outside for read-only access
RecipeController.Recipe = Recipe
RecipeController.Recipe.OPTION_ID = INDEX_OPTION_ID
RecipeController.Recipe.OPTION_AMOUNT = INDEX_OPTION_AMOUNT

---new
---constructs a new Recipe instance from the provided data.
---
---the ingredientsTable must follow this format:
---ingredientsTable = {
---		--ingredient slots in recipe, between 1 and 3
---		[1] = {
---			--options for that slot, between 1 and 6
---			[1] = {
---				--each option's ID and amount
---				[Recipe.OPTION_ID] = string,
---				[Recipe.OPTION_STACK_SIZE] = number,
---			},
---			[2] = ...
---		},
---		[2] = ...
---}
---
---@param buildingArray table array of the names of buildings that make this recipe
---@param grade number of stars, between 0 and 3
---@param time number of seconds to produce
---@param productName string name of the good produced
---@param productStackSize number of goods produced each cycle
---@param ingredientsTable table array of 1-3 ingredient slots, each with 1-6 options, each with name and amount (see doc above)
function Recipe.new(buildingArray, grade, time, productName, productStackSize, ingredientsTable)

	local instance = {}
	setmetatable(instance, { __index = Recipe} ) -- allow this instance to use Recipe class methods

	if not buildingArray or type(buildingArray) ~= "table" or #buildingArray < 1 then
		error("Cannot construct new Recipe with an empty building list")
	end
	instance[INDEX_RECIPE_BUILDINGS_ARRAY] = buildingArray

	if not grade or grade == "" then
		error("Cannot construct new Recipe with an empty grade.")
	elseif type(grade) ~= "number" or grade > 4 or grade < 0 then
		error ("Cannot construct new Recipe with an invalid grade value")
	end
	instance[INDEX_RECIPE_GRADE] = grade

	if not time or time == "" then
		error ("Cannot construct new Recipe with an empty production time")
	elseif type(time) ~= "number" or time < 0 then
		error ("Cannot construct new Recipe with an invalid production time value")
	end
	instance[INDEX_RECIPE_TIME] = time

	if not productName or productionName == "" then
		error ("Cannot construct new Recipe with an empty product name")
	end
	instance[INDEX_RECIPE_PRODUCT_NAME] = productName

	if not productStackSize or productStackSize == "" then
		error("Cannot construct new Recipe with an empty product amount")
	elseif type(productStackSize) ~= "number" or productStackSize < 1 then
		error("Cannot construct new Recipe with an invalid product amount value")
	end
	instance[INDEX_RECIPE_PRODUCT_AMOUNT] = productStackSize

	if not ingredientsTable or type(ingredientsTable) ~= "table" then
		error("Cannot construct new Recipe with an invalid ingredients table")
	end
	if #ingredientsTable > 3 then
		error("Cannot construct new Recipe with an ingredients table larger than 3 subtables")
	end
	for i, optionsArray in ipairs(ingredientsTable) do
		if not optionsArray or type(optionsArray) ~= "table" or #optionsArray < 1 then
			error("Cannot construct new Recipe with an empty options list (at index " .. i .. ")")
		end
		if #optionsArray > 6 then
			error("Cannot construct new Recipe with an options array larger than 6 subtables (at index " .. i .. ")")
		end
		for j, option in ipairs(optionsArray) do
			if not option or type(option) ~= "table" then
				error("Cannot construct new Recipe with an empty option (at index " .. i .. ", " .. j .. ")")
			end
			if not option[INDEX_OPTION_ID] or option[INDEX_OPTION_ID] == "" then
				error("Cannot construct a new Recipe with an empty option ID (at index " .. i .. ", " .. j .. ")")
			end
			if not option[INDEX_OPTION_AMOUNT] or type(option[INDEX_OPTION_AMOUNT]) ~= "number" then
				error("Cannot construct a new Recipe with an empty option amount (at index " .. i .. ", " .. j .. ")")
			end
			if option[INDEX_OPTION_AMOUNT] < 1 then
				error("Cannot construct a new Recipe with an invalid option amount (at index" .. i .. ", " .. j .. ")")
			end
		end
	end
	instance[INDEX_RECIPE_INGREDIENTS] = ingredientsTable

	return instance
end

---addBuilding
---Adds the provided building to this Recipe object's list of buildings where the recipe is made.
---
---@param buildingName string name
function Recipe:addBuilding(buildingName)

	if not self[INDEX_RECIPE_BUILDINGS_ARRAY] then
		self[INDEX_RECIPE_BUILDINGS_ARRAY] = { buildingName }
	else
		-- Skip duplicates. It shouldn't happen in 99% of cases, but just to be sure.
		for _, existingBuilding in ipairs(self[INDEX_RECIPE_BUILDINGS_ARRAY]) do
			if existingBuilding == buildingName then
				return
			end
		end
		table.insert(self[INDEX_RECIPE_BUILDINGS_ARRAY], buildingName)
	end
end

---getNumIngredients
---The number of ingredient slots (0-3) in the Recipe object.
---
---@return number of ingredients slots
function Recipe:getNumIngredients()

	if not self[INDEX_RECIPE_INGREDIENTS] then
		return 0
	end
	
	return #self[INDEX_RECIPE_INGREDIENTS]
end

--endregion



--region Public methods

function RecipeController.main(frame)

	local requiredProduct = frame.args.product
	local requiredBuilding = frame.args.building
	local requiredIngredient = frame.args.ingredient
	local displayOverride = frame.args.display

	-- recipeList is a 3-factor array of Recipe objects, by [product][grade][stackSize]
	local recipeList = getRecipesFromAllDataModels(requiredProduct, requiredBuilding, requiredIngredient)

	local numRecipes = countRecipes(recipeList)
	local maxIngredients = countMaxIngredients(recipeList)
	local caption = calculateCaption(requiredProduct, requiredBuilding, requiredIngredient, numRecipes)

	local retStart = frame:expandTemplate{
		title = VIEW_TEMPLATE_START,
		args = {
			["maxingredients"] = maxIngredients,
			["caption"] = caption,
		}
	}

	local retMiddle = buildMiddle(frame, recipeList, maxIngredients, requiredProduct, requiredBuilding, requiredIngredient)

	local retEnd = frame:expandTemplate{
		title = VIEW_TEMPLATE_END,
		args = {},
	}

	return retStart .. retMiddle .. retEnd
end

--endregion

return RecipeController