Module:RecipeController: Difference between revisions

From Against the Storm Official Wiki
m (Fixing count of recipes for building-based table)
(Swapping out old ServicesRecipesData model for new data model; now calls new service link view template)
 
(7 intermediate revisions by the same user not shown)
Line 1: Line 1:
---
--- @module RecipeController
--- Module for merging recipe information from data managers.
---
--- @module RecipeData
local RecipeController = {}
local RecipeController = {}


Line 9: Line 6:
--region Dependencies
--region Dependencies


local RecipeView = require("Module:RecipeView")
local BuildingDataProxy = require("Module:BuildingDataProxy")


local WorkshopsRecipesData = require("Module:WorkshopsRecipesData")
local DataModelsWithRecipes = {
local ServicesRecipesData = require("Module:ServicesRecipesData")
require("Module:CampsData"),
require("Module:FarmsData"),
require("Module:FishingData"),
require("Module:GatheringData"),
require("Module:InstitutionsData"),
require("Module:RainCollectorsData"),
require("Module:WorkshopsData"),
}


local GoodsData = require("Module:GoodsData")
--For looking up services
local InstitutionsData = require("Module:InstitutionsData")
local InstitutionsData = require("Module:InstitutionsData")
local WorkshopsData = require("Module:WorkshopsData")
local FarmsData = require("Module:FarmsData")
local CampsData = require("Module:CampsData")
local CollectorsData = require("Module:CollectorsData")


local GoodsData = require("Module:GoodsData")
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"
--This doesn't use the view version because we don't want to hardcode the icon filename.
local TEMPLATE_SERVICE_LINK = "Service_link"


--endregion
--endregion
Line 26: Line 35:




--region Private member variables
--region Private constants
 
local ARG_DISPLAY_OVERRIDE_LIST = "list"
 
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_IS_SERVICE = "isRecipeService"
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',
}


--endregion
--- 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 MARKUP_NEWLINE_FORCED = "\n<!-- -->\n"


local ERROR_MESSAGE_EMPTY_PARAMETERS = "You must specify a product, building, or ingredient. Please see the template documentation for how to use the parameters"


--region Private constants
--endregion


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"


--region Private member variables
--none!
--endregion
--endregion


Line 49: Line 89:
--region Private methods
--region Private methods


---getFlatRecipeValues
---Extracts a handful of values from the provided recipe pair.
---
---
--- Get the ingredients from the specified recipe and return a list of
---@param DataModel BaseDataModel a data model that implements the recipe query interface
--- the names and icons of the ingredients for that recipe.
---@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(DataModel, recipeData)
 
local buildingID = DataModel:getRecipeBuildingID(recipeData)
local grade = DataModel:getRecipeGrade(recipeData)
local time = DataModel:getRecipeTime(recipeData)
local productID = DataModel:getRecipeProductID(recipeData)
local productAmount = DataModel: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 recipeID string the ID of the recipe from which to extract ingredients
---@param DataModel BaseDataModel a data model that implements the recipe query interface
---@return table of ingredients and options (stack size, name, and icon)
---@param recipeData table pair of recipe data retrieved from a data model
local function extractIngredientsNamesAndIcons(recipeID)
---@return table nested ingredients, options (ID and amount)
local function buildingIngredientsTable(DataModel, recipeData)


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


if not ingredientsList[i] then
if not ingredientsTable[i] then
ingredientsList[i] = {}
ingredientsTable[i] = {}
end
end
 
for j = 1, DataModel:getRecipeIngredientNumOptions(recipeData, i) do
local goodID, stackSize = WorkshopsRecipesData.getRecipeOptionByID(recipeID, i, j)
ingredientsTable[i][j] = {
ingredientsList[i][j] = {}
[INDEX_OPTION_ID] = DataModel:getRecipeIngredientOptionIDAt(recipeData, i, j),
ingredientsList[i][j][INDEX_OPTION_STACK_SIZE] = stackSize
[INDEX_OPTION_AMOUNT] = DataModel:getRecipeIngredientOptionAmountAt(recipeData, i, j),
ingredientsList[i][j][INDEX_OPTION_GOOD_NAME] = GoodsData.getGoodNameByID(goodID)
}
ingredientsList[i][j][INDEX_OPTION_GOOD_ICON] = GoodsData.getGoodIconByID(goodID)
end
end
end
end


return ingredientsList
return ingredientsTable
end
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.
---
---
--- Get the ingredients from the specified recipe and return a list of
---Benchmarking: ~0.0003 seconds
--- the names and icons of the ingredients for that recipe.
---
---
---@param recipeID string the ID of the recipe from which to extract ingredients
---@param DataModel BaseDataModel a data model that implements the recipe query interface
---@return table of ingredients and options (stack size, name, and icon)
---@param productID string the ID of the product, or nil if any
local function extractServiceGoodsNamesAndIcons(recipeID)
---@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 ingredientsList = {}
local rawRecipeList = {}
-- Need to convert IDs to names and icons and keep stack size.
-- Check for combos first
for i = 1, ServicesRecipesData.getRecipeNumberOfServiceGoodsByID(recipeID) do
if productID and buildingID then
 
rawRecipeList = DataModel:getIDsAndRecipesWhereProductIDAndBuildingID(productID, buildingID)
ingredientsList[i] = {}
elseif productID and ingredientID then
 
rawRecipeList = DataModel:getIDsAndRecipesWhereProductIDAndIngredientID(productID, ingredientID)
-- Need to structure this the same way as when there are several
elseif ingredientID and buildingID then
-- ingredient option groups. But for services there's just one.
rawRecipeList = DataModel:getIDsAndRecipesWhereIngredientIDAndBuildingID(ingredientID, buildingID)
local j = 1
elseif productID then
ingredientsList[i][j] = {}
rawRecipeList = DataModel:getIDsAndRecipesWhereProductID(productID)
local goodID, stackSize = ServicesRecipesData.getRecipeOptionByID(recipeID, i)
elseif ingredientID then
ingredientsList[i][j][INDEX_OPTION_STACK_SIZE] = stackSize
rawRecipeList = DataModel:getIDsAndRecipesWhereIngredientID(ingredientID)
ingredientsList[i][j][INDEX_OPTION_GOOD_NAME] = GoodsData.getGoodNameByID(goodID)
elseif buildingID then
ingredientsList[i][j][INDEX_OPTION_GOOD_ICON] = GoodsData.getGoodIconByID(goodID)
rawRecipeList = DataModel:getIDsAndRecipesWhereBuildingID(buildingID)
else
error(ERROR_MESSAGE_EMPTY_PARAMETERS)
end
end


return ingredientsList
return rawRecipeList
end
end


 
---compileRecipeLists
 
---Adds the second list to the first, but restructures into Recipe objects along the way.
 
---
---
--- Main engine that builds the view based on the list of buildings from
---@param DataModel BaseDataModel a data model that implements the recipe query interface
--- workshops, which are slightly different than other building lists.
---@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
---@param recipeListFromWorkshops table the list of recipes to render
---@return table the same recipeObjectTable, but with new and updated entries
---@param displayOverride string to control if the display isn't default
local function compileRecipeLists(DataModel, recipeObjectTable, rawRecipeTable)
---@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
for _, pair in ipairs(rawRecipeTable) do
-- recipes, skip it by getting ipairs on an empty table.
for _, recipeID in ipairs(recipeListFromWorkshops or {}) do


local gradeStars = WorkshopsRecipesData.getRecipeGradeByID(recipeID)
local buildingID, grade, time, productID, productAmount = getFlatRecipeValues(DataModel, pair)
local productionTime = WorkshopsRecipesData.getRecipeProductionTimeByID(recipeID)
local ingredientsTable = buildingIngredientsTable(DataModel, pair)
local productID, productStackSize = WorkshopsRecipesData.getRecipeProductByID(recipeID)


local ingredientsList = extractIngredientsNamesAndIcons(recipeID)
local buildingName = BuildingDataProxy.getName(buildingID)


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


local buildingListFromWorkshops = WorkshopsData.getWorkshopNamesWithRecipeID(recipeID)
-- 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
for _, buildingName in ipairs(buildingListFromWorkshops) do
recipeObjectTable[productName] = {}
 
end
-- If we're screening for a building (not nil), then we have
-- Transform the grade values to store them in a natural order.
-- to match the current building
if not recipeObjectTable[productName][STORE_GRADES[grade]] then
if not requiredBuilding or buildingName == requiredBuilding then
recipeObjectTable[productName][STORE_GRADES[grade]] = {}
 
end
local buildingIcon = WorkshopsData.getWorkshopIcon(buildingName)
if not recipeObjectTable[productName][STORE_GRADES[grade]][productAmount] then
 
-- Create a new Recipe object at this place in the table.
RecipeView.addRowForRecipe(displayOverride, numIngredients,
recipeObjectTable[productName][STORE_GRADES[grade]][productAmount] = RecipeController.Recipe.new( { buildingName }, grade, time, isService, productID, productAmount, ingredientsTable)
buildingName, buildingIcon,
else
gradeStars, productionTime, ingredientsList,
-- Add the building to the existing Recipe object at this place in the table.
productStackSize, productName, productIcon, requiredBuilding)
recipeObjectTable[productName][STORE_GRADES[grade]][productAmount]:addBuilding(buildingName)
 
end
end
end
end


end
return recipeObjectTable
end
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.
---
---
--- Main engine that builds the view based on the list of buildings from
---For example, finding the recipe for Biscuits in the Field Kitchen: recipeObjectArray["Biscuits"][0][10]
--- services, which are slightly different than other building lists.
---
---
---@param recipeListFromServices table the list of recipes to render
---@param requiredProduct string the name of the product, or nil if any
---@param displayOverride string to control if the display isn't default
---@param requiredBuilding string the name of the building, or nil if any
---@param numIngredients number of ingredient columns to display
---@param requiredIngredient string the name of the ingredient, or nil if any
---@param requiredBuilding string the name of the required building
---@return table a 3-factor compiled table of Recipe objects, [product][grade][amount]
local function buildRowsForServices(recipeListFromServices, displayOverride, numIngredients, requiredBuilding)
local function getRecipesFromAllDataModels(requiredProduct, requiredBuilding, requiredIngredient)


-- Same. If it's nil and there are no recipes, skip it by getting ipairs
--Resolve names to IDs, start them all as nil as wildcards.
-- on an empty table.
local productID
for _, recipeID in ipairs(recipeListFromServices or {}) do
local buildingID
local ingredientID


local gradeStars = ServicesRecipesData.getRecipeGradeByID(recipeID)
if requiredProduct then
local serviceName = ServicesRecipesData.getRecipeServiceNameByID(recipeID)
productID = GoodsData.getGoodID(requiredProduct)
local serviceIcon = ServicesRecipesData.getRecipeServiceIconByID(recipeID)
-- If it's not a good, then it might be a service, which has ID == name, so set it directly to the name if it's a valid service name
local list = InstitutionsData:getIDsAndRecipesWhereProductID(requiredProduct)
if not productID and #list > 0 then
productID = requiredProduct
end
end


local ingredientsList = extractServiceGoodsNamesAndIcons(recipeID)
if requiredBuilding then
buildingID = BuildingDataProxy.getID(requiredBuilding)
end


local buildingListFromInstitutions = InstitutionsData.getInstitutionNamesWithRecipeID(recipeID)
-- Ingredients are always goods, never services.
if requiredIngredient then
ingredientID = GoodsData.getGoodID(requiredIngredient)
end


local productionTime -- = nil
recipeObjectArray = {}
local resultStackSize -- = nil
for _, dataModel in ipairs(DataModelsWithRecipes) do
local newRecipeList = getRawRecipes(dataModel, productID, buildingID, ingredientID)
recipeObjectArray = compileRecipeLists(dataModel, recipeObjectArray, newRecipeList)
end


for _, buildingName in ipairs(buildingListFromInstitutions) do
return recipeObjectArray
end


-- If we're screening for a building (not nil), then we have
---countMaxIngredients
-- to match the current building
---Scans through the provided table of Recipe objects to count them.
if not requiredBuilding or buildingName == requiredBuilding then
---
---@param recipeList table 3-factor table of Recipe objects
---@return number of recipe objects
local function countRecipes(recipeList)


local buildingIcon = InstitutionsData.getInstitutionIcon(buildingName)
local count = 0
 
for _, product in pairs(recipeList) do
RecipeView.addRowForRecipe(displayOverride, numIngredients,
for _, grade in pairs(product) do
buildingName, buildingIcon,
for _, _ in pairs(grade) do
gradeStars, productionTime, ingredientsList,
count = count + 1
resultStackSize, serviceName, serviceIcon, requiredBuilding)
end
end
end
end
end
end
return count
end
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).
---
--- 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 recipeList table 3-factor table of Recipe objects
---@param displayOverride string to control if the display isn't default
---@return number of ingredients slots required to represent them all
---@param numIngredients number of ingredient columns to display
local function countMaxIngredients(recipeList)
---@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 max = 0
local gradeStars, plantingTime, harvestingTime = FarmsData.getFarmRecipeStats(buildingName, i)
for _, product in pairs(recipeList) do
local ingredientsList = {}
for _, grade in pairs(product) do
local productName = GoodsData.getGoodNameByID(goodID)
for _, recipe in pairs(grade) do
local productIcon = GoodsData.getGoodIconByID(goodID)
local num = recipe:getNumIngredients()
 
if max < num then
RecipeView.addRowForRecipe(displayOverride, numIngredients,
max = num
buildingName, buildingIcon,
gradeStars, (plantingTime + harvestingTime),
ingredientsList, stackSize,
productName, productIcon, requiredBuilding)
end
end
end
end
end
end
end
end
return max
end
end


 
---calculateCaption
 
---Simple cascading of rewriting the author requirements into the caption and how many were returned.
---
---
--- Main engine that builds the view based on the list of buildings from
---@param requiredProduct string name of product, or nil if any
--- camps, which are slightly different than other building lists.
---@param requiredBuilding string name of building, or nil if any
---
---@param requiredIngredient string name of ingredient, or nil if any
---@param buildingListFromCamps table the list of recipes to render
---@param numRecipes number of recipes
---@param displayOverride string to control if the display isn't default
---@return string the caption
---@param numIngredients number of ingredient columns to display
local function calculateCaption(requiredProduct, requiredBuilding, requiredIngredient, numRecipes)
---@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
local caption = numRecipes .. " recipes"


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


for i = 1, CampsData.getCampNumberOfRecipes(buildingName) do
if requiredIngredient then
 
caption = caption .. " using " .. requiredIngredient
local goodID, stackSize = CampsData.getCampRecipeProduct(buildingName, i)
end
 
-- 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


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




 
---addBuildingLinks
---Assistant to buildMiddle.
---
---
--- Main engine that builds the view based on the list of recipes from
---@param frame table
--- collectors, , which are slightly different than other building lists.
---@param recipe table
---
---@param requiredBuilding string
---@param buildingListFromCollectors table the list of recipes to render
---@return string
---@param displayOverride string to control if the display isn't default
local function addBuildingLinks(frame, recipe, requiredBuilding)
---@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
local ret = ""
local numBuildings = #recipe[INDEX_RECIPE_BUILDINGS_ARRAY]


-- If we're screening for a building (not nil), then we have to match
for _, buildingName in ipairs(recipe[INDEX_RECIPE_BUILDINGS_ARRAY]) do
-- the current building
if not requiredBuilding or buildingName == requiredBuilding then


for i = 1, CollectorsData.getCollectorNumberOfRecipes(buildingName) do
local buildingLinkArgs = {}


local goodID, stackSize = CollectorsData.getCollectorRecipeProduct(buildingName, i)
buildingLinkArgs["name"] = buildingName
buildingLinkArgs["iconfilename"] = BuildingDataProxy.getIcon(BuildingDataProxy.getID(buildingName))


-- If we're screening for a productID (not nil), then we have to
buildingLinkArgs["iconsize"] = (numBuildings < 2) and VIEW_TABLE_BUILDING_SINGLE_ICON_SIZE or VIEW_TABLE_BUILDING_MULTIPLE_ICON_SIZE
-- match the current goodID
if not productID or goodID == productID then


local buildingIcon = CollectorsData.getCollectorIcon(buildingName)
-- Redundant to label the building if the author required it--the image alt-text and link still work with name.
local gradeStars, productionTime = CollectorsData.getCollectorRecipeStats(buildingName, i)
if requiredBuilding then
local ingredientsList = {}
buildingLinkArgs["display"] = "notext"
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


ret = ret .. "\n|-\n| " .. frame:expandTemplate{
title = VIEW_BUILDING_LINK,
args = buildingLinkArgs,
}
end
end
return ret
end
end






---addIngredientSlot
---Assistant to buildMiddle.
---
---
--- Takes two lists and returns the intersection of the two. Slightly more
---@param frame table
--- efficient if the second list is smaller than the first.
---@param ingredientSlot table
---
---@return string
---@param list1 table a flat table (list of values)
local function addIngredientSlot(frame, ingredientSlot)
---@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 = {}
local innerTable = MARKUP_NEWLINE_FORCED .. "{|" --all table markup has to start on its own line; this html comment accomplishes this
local numOptions = #ingredientSlot


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


-- Check for intersection and populate the result table.
for _, option in ipairs(ingredientSlot) do
for _, valueToFind in ipairs(list1 or {}) do
local rlArgs = {}
if setList2[valueToFind] then
rlArgs["name"] = GoodsData.getName(option[INDEX_OPTION_ID])
table.insert(intersection, valueToFind)
rlArgs["iconfilename"] = GoodsData.getIcon(option[INDEX_OPTION_ID])
end
rlArgs["iconsize"] = VIEW_TABLE_INGREDIENT_ICON_SIZE
end


if #intersection == 0 then
innerTable = innerTable .. "\n|-\n| " .. option[INDEX_OPTION_AMOUNT] .. " |"
return nil
.. "| " .. frame:expandTemplate{
title = VIEW_RESOURCE_LINK,
args = rlArgs,
}
end
end


return intersection
return innerTable .. "\n|}\n"
end
end
--endregion






--region Public methods
---addProductLinks
 
---Assistant to buildMiddle.
---
---
--- Compiles the data, organizes it appropriately and in a way that protects
---@param frame table
--- the view from needing the details, and sends it to the view for rendering
---@param recipe table
--- before returning it to the template.
---@param requiredProduct string
---
---@return string
---@param ingredientName string the ingredient to base the table on
local function addProductLinks(frame, recipe, requiredProduct)
---@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 link = ""
local ingredientID = GoodsData.getGoodID(ingredientName)
if recipe[INDEX_RECIPE_IS_SERVICE] then
if not ingredientID then
local serviceLinkArgs = {}
return "No ingredient found with that name: " .. ingredientName .. "."
serviceLinkArgs["name"] = recipe[INDEX_RECIPE_PRODUCT_NAME]
end
serviceLinkArgs["size"] = VIEW_TABLE_PRODUCT_ICON_SIZE
local recipeListFromWorkshopsRecipes = WorkshopsRecipesData.getAllRecipeIDsWithIngredientID(ingredientID)
-- Redundant to label the resource if the author required it--the image alt-text and link still work with name.
local recipeListFromWorkshops = WorkshopsData.getAllWorkshopRecipes(buildingName)
if requiredProduct then
recipeListFromWorkshops = findIntersection(recipeListFromWorkshopsRecipes, recipeListFromWorkshops)
serviceLinkArgs["display"] = "notext"
end


local recipeListFromServicesRecipes = ServicesRecipesData.getAllRecipeIDsWithServiceGoodID(ingredientID)
link = frame:expandTemplate{
local recipeListFromServices = InstitutionsData.getAllInstitutionRecipes(buildingName)
title = TEMPLATE_SERVICE_LINK,
recipeListFromServices = findIntersection(recipeListFromServicesRecipes, recipeListFromServices)
args = serviceLinkArgs,
 
}
-- Find out what the largest number of ingredients in this table is. Need
else
-- 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 resourceLinkArgs = {}
local numRecipes = 0
resourceLinkArgs["name"] = GoodsData.getName(recipe[INDEX_RECIPE_PRODUCT_NAME])
local header -- = nil
resourceLinkArgs["iconfilename"] = GoodsData.getIcon(recipe[INDEX_RECIPE_PRODUCT_NAME])
if recipeListFromWorkshops ~= nil then
resourceLinkArgs["iconsize"] = VIEW_TABLE_PRODUCT_ICON_SIZE
numRecipes = numRecipes + #recipeListFromWorkshops
-- Redundant to label the resource if the author required it--the image alt-text and link still work with name.
header = TABLE_HEADER_PRODUCT
if requiredProduct then
end
resourceLinkArgs["display"] = "notext"
if recipeListFromServices ~= nil then
if numRecipes > 0 then
header = TABLE_HEADER_BOTH
else
header = TABLE_HEADER_SERVICE
end
end
numRecipes = numRecipes + #recipeListFromServices
end


if numRecipes == 0 then
link = frame:expandTemplate{
return "No recipes found for ingredient " .. ingredientName .. " in the building " .. buildingName .. "."
title = VIEW_RESOURCE_LINK,
args = resourceLinkArgs,
}
end
end


RecipeView.startViewForIngredientAndBuilding(ingredientName, buildingName, displayOverride, numRecipes, numIngredients, header)
return link
 
buildRowsForWorkshops(recipeListFromWorkshops, displayOverride, numIngredients, buildingName)
 
buildRowsForServices(recipeListFromServices, displayOverride, numIngredients, buildingName)
 
return RecipeView.endView(displayOverride)
end
end






---buildMiddle
---Calls the view to render table rows for each Recipe object and links to buildings and resources within the table rows.
---
---
--- Compiles the data, organizes it appropriately and in a way that protects
---Benchmarking: ~0.005 seconds
--- 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 frame table MediaWiki template context
---@param displayOverride string to control the display if not default
---@param recipeList table 3-factor list of Recipe objects, by [product][grade][amount]
local function renderWithIngredient(ingredientName, displayOverride)
---@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


-- Service and workshops recipes have ingredients. Camps and farms do not.
local rowArgs = {}
local ingredientID = GoodsData.getGoodID(ingredientName)
rowArgs["maxingredients"] = maxIngredients
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
rowArgs["building"] = MARKUP_NEWLINE_FORCED .. "{|" --all table markup has to start on its own line; this html comment accomplishes this
-- 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.
rowArgs["building"] = rowArgs["building"]
local numRecipes = 0
.. addBuildingLinks(frame, recipe, requiredBuilding) .. "\n|}"
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
rowArgs["grade"] = frame:expandTemplate{
return "No recipes found for ingredient: " .. ingredientName .. "."
title = VIEW_GRADES[recipe[INDEX_RECIPE_GRADE]],
end
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)


RecipeView.startViewForIngredient(ingredientName, displayOverride, numRecipes, numIngredients, header)
for i, ingredientSlot in ipairs(recipe[INDEX_RECIPE_INGREDIENTS]) do
rowArgs["ingredient" .. i] = addIngredientSlot(frame, ingredientSlot)
end


buildRowsForWorkshops(recipeListFromWorkshops, displayOverride, numIngredients)
rowArgs["product"] = recipe[INDEX_RECIPE_PRODUCT_AMOUNT] .. "&nbsp;"
.. addProductLinks(frame, recipe, requiredProduct)


buildRowsForServices(recipeListFromServices, displayOverride, numIngredients)
ret = ret .. frame:expandTemplate{
title = VIEW_TEMPLATE_ROW,
args = rowArgs,
}
ret = ret .. "\n"
end
end
end


return RecipeView.endView(displayOverride)
return ret
end
end


---renderListView
---Takes the table of recipes gathered from the data models and returns a markup-unordered-list of the recipes. Buildings are shown when the author requested the product, otherwise the products are shown.
---
---@param frame table the Mediawiki context for the template
---@param recipeList table 3-factor table of Recipe objects in [product][grade][amount]
---@param requiredProduct string name of the product, or nil if any
---@param _ string name of the building, or nil if any (unused)
---@param _ string name of the ingredient, or nil if any (unused)
local function renderListView(frame, recipeList, requiredProduct, _, _)


local ret = ""
for _, recipeProductSubtable in pairs(recipeList) do
for _, recipeGradeSubtable in pairs(recipeProductSubtable) do
for _, recipe in pairs(recipeGradeSubtable) do
for buildingCount, buildingName in ipairs(recipe[INDEX_RECIPE_BUILDINGS_ARRAY]) do


---
local rowText = '\n*<span class="nowrap">'
--- 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
if requiredProduct then
local productID = GoodsData.getGoodID(productName)
-- When queried by product, show the building name
rowText = rowText .. frame:expandTemplate{
title = VIEW_BUILDING_LINK,
args = {
["name"] = buildingName,
["iconsize"] = "none",
},
}
else
-- If past the first building and we're not showing building names, then this will create duplicate entries.
if buildingCount > 1 then
break
end


local recipeListFromWorkshopsRecipes
-- When queried by building or ingredient, show the product name
if productID then
if recipe[INDEX_RECIPE_IS_SERVICE] then
recipeListFromWorkshopsRecipes = WorkshopsRecipesData.getAllRecipeIDsForProductID(productID)
rowText = rowText .. frame:expandTemplate{
end
title = TEMPLATE_SERVICE_LINK,
local recipeListFromWorkshops = WorkshopsData.getAllWorkshopRecipes(buildingName)
args = {
recipeListFromWorkshops = findIntersection(recipeListFromWorkshopsRecipes, recipeListFromWorkshops)
["name"] = recipe[INDEX_RECIPE_PRODUCT_NAME],
["size"] = "small",
}
}
else
rowText = rowText .. frame:expandTemplate{
title = VIEW_RESOURCE_LINK,
args = {
["name"] = GoodsData.getName(recipe[INDEX_RECIPE_PRODUCT_NAME]),
["iconfilename"] = GoodsData.getIcon(recipe[INDEX_RECIPE_PRODUCT_NAME]),
["iconsize"] = "small",
},
}
end
end


-- It could be the name of a service instead of the name of a product.
rowText = rowText .. "&nbsp;("
local recipeListFromServicesRecipes = ServicesRecipesData.getAllRecipeIDsForServiceName(productName)
rowText = rowText .. frame:expandTemplate{
local recipeListFromServices = InstitutionsData.getAllInstitutionRecipes(buildingName)
title = VIEW_GRADES[recipe[INDEX_RECIPE_GRADE]],
recipeListFromServices = findIntersection(recipeListFromServicesRecipes, recipeListFromServices)
args = {},
}


-- These result in a list of buildings, not recipes.
rowText = rowText .. ")</span>"
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
ret = ret .. rowText
-- this before we start building any of the beginning and the middle so
end
-- 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
end
end
end


-- Count the number of recipes in all the lists
return ret
local numRecipes = 0
end
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
--endregion
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)
--region Public classes
end


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.
---
---
--- Compiles the data, organizes it appropriately and in a way that protects
---the ingredientsTable must follow this format:
--- the view from needing the details, and sends it to the view for rendering
---ingredientsTable = {
--- before returning it to the template.
--- --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 productName string the product to base the table on
---@param buildingArray table array of the names of buildings that make this recipe
---@param displayOverride string to control the display if not default
---@param grade number of stars, between 0 and 3
local function renderWithProduct(productName, displayOverride)
---@param time number of seconds to produce
---@param productName string name of the good produced
---@param isService boolean true if this recipe offers a service instead of a product
---@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, isService, productName, productStackSize, ingredientsTable)


-- Get lists from all buildings
local instance = {}
local productID = GoodsData.getGoodID(productName)
setmetatable(instance, { __index = Recipe} ) -- allow this instance to use Recipe class methods


local recipeListFromWorkshops
if not buildingArray or type(buildingArray) ~= "table" or #buildingArray < 1 then
if productID then
error("Cannot construct new Recipe with an empty building list")
recipeListFromWorkshops = WorkshopsRecipesData.getAllRecipeIDsForProductID(productID)
end
end
-- It could be the name of a service instead of the name of a product.
instance[INDEX_RECIPE_BUILDINGS_ARRAY] = buildingArray
local recipeListFromServices = ServicesRecipesData.getAllRecipeIDsForServiceName(productName)


-- These result in a list of buildings, not recipes.
if not grade or grade == "" then
local buildingListFromCamps
error("Cannot construct new Recipe with an empty grade.")
local buildingListFromFarms
elseif type(grade) ~= "number" or grade > 4 or grade < 0 then
local buildingListFromCollectors
error ("Cannot construct new Recipe with an invalid grade value")
if productID then
buildingListFromCamps = CampsData.getCampNamesWithRecipeProductID(productID)
buildingListFromFarms = FarmsData.getFarmNamesWithRecipeProductID(productID)
buildingListFromCollectors = CollectorsData.getCollectorNamesWithRecipeProductID(productID)
end
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


-- Find out what the largest number of ingredients in this table is. Need
if type(isService) ~= "boolean" then
-- this before we start building any of the beginning and the middle so
error("Cannot construct new Recipe with an invalid service flag")
-- 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
end
if recipeListFromServices then
instance[INDEX_RECIPE_IS_SERVICE] = isService
for _, recipeID in ipairs(recipeListFromServices) do
 
local thisNum = ServicesRecipesData.getRecipeNumberOfServiceGoodsByID(recipeID)
if not productName or productionName == "" then
if numIngredients < thisNum then
error ("Cannot construct new Recipe with an empty product name")
numIngredients = thisNum
end
end
end
end
instance[INDEX_RECIPE_PRODUCT_NAME] = productName


-- Count the number of recipes in all the lists
if not productStackSize or productStackSize == "" then
local numRecipes = 0
error("Cannot construct new Recipe with an empty product amount")
local header -- = nil
elseif type(productStackSize) ~= "number" or productStackSize < 1 then
if recipeListFromWorkshops ~= nil then
error("Cannot construct new Recipe with an invalid product amount value")
numRecipes = numRecipes + #recipeListFromWorkshops
header = TABLE_HEADER_PRODUCT
end
end
if buildingListFromCamps ~= nil then
instance[INDEX_RECIPE_PRODUCT_AMOUNT] = productStackSize
numRecipes = numRecipes + #buildingListFromCamps
 
header = TABLE_HEADER_PRODUCT
if not ingredientsTable or type(ingredientsTable) ~= "table" then
error("Cannot construct new Recipe with an invalid ingredients table")
end
end
if buildingListFromFarms ~= nil then
if #ingredientsTable > 3 then
numRecipes = numRecipes + #buildingListFromFarms
error("Cannot construct new Recipe with an ingredients table larger than 3 subtables")
header = TABLE_HEADER_PRODUCT
end
end
if buildingListFromCollectors ~= nil then
for i, optionsArray in ipairs(ingredientsTable) do
numRecipes = numRecipes + #buildingListFromCollectors
if not optionsArray or type(optionsArray) ~= "table" or #optionsArray < 1 then
header = TABLE_HEADER_PRODUCT
error("Cannot construct new Recipe with an empty options list (at index " .. i .. ")")
end
end
if recipeListFromServices ~= nil then
if #optionsArray > 6 then
if numRecipes > 0 then
error("Cannot construct new Recipe with an options array larger than 6 subtables (at index " .. i .. ")")
header = TABLE_HEADER_BOTH
end
else
for j, option in ipairs(optionsArray) do
header = TABLE_HEADER_SERVICE
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
numRecipes = numRecipes + #recipeListFromServices
end
end
instance[INDEX_RECIPE_INGREDIENTS] = ingredientsTable


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


RecipeView.startViewForProduct(productName, displayOverride, numRecipes, numIngredients, header)
---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)


buildRowsForWorkshops(recipeListFromWorkshops, displayOverride, numIngredients)
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


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


buildRowsForFarms(buildingListFromFarms, displayOverride, numIngredients, productID)
if not self[INDEX_RECIPE_INGREDIENTS] then
return 0
end


buildRowsForCamps(buildingListFromCamps, displayOverride, numIngredients, productID)
return #self[INDEX_RECIPE_INGREDIENTS]
end


buildRowsForCollectors(buildingListFromCollectors, displayOverride, numIngredients, productID)
--endregion


return RecipeView.endView(displayOverride)
end




--region Public methods


---main
---Called from Template:Recipe. Returns markup text for display by using external view templates.
---
---
--- Compiles the data, organizes it appropriately and in a way that protects
---@param frame table the Mediawiki calling context for the template
--- the view from needing the details, and sends it to the view for rendering
---@return string wiki markup
--- before returning it to the template.
function RecipeController.main(frame)
---
---@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 requiredProduct = frame.args.product
local recipeListFromServices = InstitutionsData.getAllInstitutionRecipes(buildingName)
local requiredBuilding = frame.args.building
local requiredIngredient = frame.args.ingredient
local displayOverride = frame.args.display


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


-- Find out what the largest number of ingredients in this table is. Need
-- recipeList is a 3-factor array of Recipe objects, by [product][grade][stackSize]
-- this before we start building any of the beginning and the middle so
local recipeList = getRecipesFromAllDataModels(requiredProduct, requiredBuilding, requiredIngredient)
-- 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
if displayOverride == ARG_DISPLAY_OVERRIDE_LIST then
local numRecipes = 0
return renderListView(frame, recipeList, requiredProduct, requiredBuilding, requiredIngredient)
local header -- = nil
if recipeListFromWorkshops ~= nil then
numRecipes = numRecipes + #recipeListFromWorkshops
header = TABLE_HEADER_PRODUCT
end
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


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,
["numrecipes"] = numRecipes,
}
}


--region Public methods
local retMiddle = buildMiddle(frame, recipeList, maxIngredients, requiredProduct, requiredBuilding, requiredIngredient)


---
local retEnd = frame:expandTemplate{
--- Called by the template. Distributes
title = VIEW_TEMPLATE_END,
---@param frame table
args = {},
function RecipeController.renderRecipe(frame)
}


-- Extract the template parameters.
return retStart .. retMiddle .. retEnd
local productName = frame.args.product or frame.args[1]
local buildingName = frame.args.building or frame.args[2]
local ingredientName = frame.args.ingredient
local displayOverride = frame.args.display
 
if ingredientName and ingredientName ~= "" and buildingName and buildingName ~= "" then
return renderWithIngredientAndBuilding(ingredientName, buildingName, displayOverride)
else
if ingredientName and ingredientName ~= "" then
return renderWithIngredient(ingredientName, displayOverride)
else
if productName and productName ~= "" and buildingName and buildingName ~= "" then
return renderWithProductAndBuilding(productName, buildingName, displayOverride)
else
if productName and productName ~= "" then
return renderWithProduct(productName, displayOverride)
else
if buildingName and buildingName ~= "" then
return renderWithBuilding(buildingName, displayOverride)
else
return "Unknown parameter in Recipe template."
end
end
end
end
end
end
end



Latest revision as of 20:29, 9 November 2024

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

--- @module RecipeController
local RecipeController = {}



--region Dependencies

local BuildingDataProxy = require("Module:BuildingDataProxy")

local DataModelsWithRecipes = {
	require("Module:CampsData"),
	require("Module:FarmsData"),
	require("Module:FishingData"),
	require("Module:GatheringData"),
	require("Module:InstitutionsData"),
	require("Module:RainCollectorsData"),
	require("Module:WorkshopsData"),
}

local GoodsData = require("Module:GoodsData")
--For looking up services
local InstitutionsData = require("Module:InstitutionsData")

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"
--This doesn't use the view version because we don't want to hardcode the icon filename.
local TEMPLATE_SERVICE_LINK = "Service_link"

--endregion



--region Private constants

local ARG_DISPLAY_OVERRIDE_LIST = "list"

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_IS_SERVICE = "isRecipeService"
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 MARKUP_NEWLINE_FORCED = "\n<!-- -->\n"

local ERROR_MESSAGE_EMPTY_PARAMETERS = "You must specify a product, building, or ingredient. Please see the template documentation for how to use the parameters"

--endregion



--region Private member variables
--none!
--endregion



--region Private methods

---getFlatRecipeValues
---Extracts a handful of values from the provided recipe pair.
---
---@param DataModel BaseDataModel a data model that implements the recipe query interface
---@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(DataModel, recipeData)

	local buildingID = DataModel:getRecipeBuildingID(recipeData)
	local grade = DataModel:getRecipeGrade(recipeData)
	local time = DataModel:getRecipeTime(recipeData)
	local productID = DataModel:getRecipeProductID(recipeData)
	local productAmount = DataModel: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 DataModel BaseDataModel a data model that implements the recipe query interface
---@param recipeData table pair of recipe data retrieved from a data model
---@return table nested ingredients, options (ID and amount)
local function buildingIngredientsTable(DataModel, recipeData)

	local ingredientsTable = {}
	for i = 1, DataModel:getRecipeNumIngredientSlots(recipeData) do

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

	return ingredientsTable
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 BaseDataModel a data model that implements the recipe query interface
---@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 = {}
	-- Check for combos first
	if productID and buildingID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereProductIDAndBuildingID(productID, buildingID)
	elseif productID and ingredientID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereProductIDAndIngredientID(productID, ingredientID)
	elseif ingredientID and buildingID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereIngredientIDAndBuildingID(ingredientID, buildingID)
	elseif productID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereProductID(productID)
	elseif ingredientID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereIngredientID(ingredientID)
	elseif buildingID then
		rawRecipeList = DataModel:getIDsAndRecipesWhereBuildingID(buildingID)
	else
		error(ERROR_MESSAGE_EMPTY_PARAMETERS)
	end

	return rawRecipeList
end

---compileRecipeLists
---Adds the second list to the first, but restructures into Recipe objects along the way.
---
---@param DataModel BaseDataModel a data model that implements the recipe query interface
---@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(DataModel, recipeObjectTable, rawRecipeTable)

	for _, pair in ipairs(rawRecipeTable) do

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

		local buildingName = BuildingDataProxy.getName(buildingID)

		-- Services identify their need by name, but goods to not. If it's a service, this is a simple renaming.
		local productName = productID
		local isService = DataModel:isRecipeProvidingService(pair)
		if not isService 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, isService, 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 might be a service, which has ID == name, so set it directly to the name if it's a valid service name
		local list = InstitutionsData:getIDsAndRecipesWhereProductID(requiredProduct)
		if not productID and #list > 0 then
			productID = requiredProduct
		end
	end

	if requiredBuilding then
		buildingID = BuildingDataProxy.getID(requiredBuilding)
	end

	-- Ingredients are always goods, never services.
	if requiredIngredient then
		ingredientID = GoodsData.getGoodID(requiredIngredient)
	end

	recipeObjectArray = {}
	for _, dataModel in ipairs(DataModelsWithRecipes) do
		local newRecipeList = getRawRecipes(dataModel, productID, buildingID, ingredientID)
		recipeObjectArray = compileRecipeLists(dataModel, 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 _, _ 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
	end

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

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


---addBuildingLinks
---Assistant to buildMiddle.
---
---@param frame table
---@param recipe table
---@param requiredBuilding string
---@return string
local function addBuildingLinks(frame, recipe, requiredBuilding)

	local ret = ""
	local numBuildings = #recipe[INDEX_RECIPE_BUILDINGS_ARRAY]

	for _, buildingName in ipairs(recipe[INDEX_RECIPE_BUILDINGS_ARRAY]) do

		local buildingLinkArgs = {}

		buildingLinkArgs["name"] = buildingName
		buildingLinkArgs["iconfilename"] = BuildingDataProxy.getIcon(BuildingDataProxy.getID(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

		ret = ret .. "\n|-\n| " .. frame:expandTemplate{
			title = VIEW_BUILDING_LINK,
			args = buildingLinkArgs,
		}
	end

	return ret
end



---addIngredientSlot
---Assistant to buildMiddle.
---
---@param frame table
---@param ingredientSlot table
---@return string
local function addIngredientSlot(frame, ingredientSlot)

	local innerTable = MARKUP_NEWLINE_FORCED .. "{|" --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

	return innerTable .. "\n|}\n"
end



---addProductLinks
---Assistant to buildMiddle.
---
---@param frame table
---@param recipe table
---@param requiredProduct string
---@return string
local function addProductLinks(frame, recipe, requiredProduct)

	local link = ""
	if recipe[INDEX_RECIPE_IS_SERVICE] then
		local serviceLinkArgs = {}
		serviceLinkArgs["name"] = recipe[INDEX_RECIPE_PRODUCT_NAME]
		serviceLinkArgs["size"] = 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
			serviceLinkArgs["display"] = "notext"
		end

		link = frame:expandTemplate{
			title = TEMPLATE_SERVICE_LINK,
			args = serviceLinkArgs,
		}
	else

		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

		link = frame:expandTemplate{
			title = VIEW_RESOURCE_LINK,
			args = resourceLinkArgs,
		}
	end

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

				rowArgs["building"] = MARKUP_NEWLINE_FORCED .. "{|" --all table markup has to start on its own line; this html comment accomplishes this

				rowArgs["building"] = rowArgs["building"]
						.. addBuildingLinks(frame, recipe, requiredBuilding) .. "\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)

				for i, ingredientSlot in ipairs(recipe[INDEX_RECIPE_INGREDIENTS]) do
					rowArgs["ingredient" .. i] = addIngredientSlot(frame, ingredientSlot)
				end

				rowArgs["product"] = recipe[INDEX_RECIPE_PRODUCT_AMOUNT] .. "&nbsp;"
						.. addProductLinks(frame, recipe, requiredProduct)

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

	return ret
end

---renderListView
---Takes the table of recipes gathered from the data models and returns a markup-unordered-list of the recipes. Buildings are shown when the author requested the product, otherwise the products are shown.
---
---@param frame table the Mediawiki context for the template
---@param recipeList table 3-factor table of Recipe objects in [product][grade][amount]
---@param requiredProduct string name of the product, or nil if any
---@param _ string name of the building, or nil if any (unused)
---@param _ string name of the ingredient, or nil if any (unused)
local function renderListView(frame, recipeList, requiredProduct, _, _)

	local ret = ""
	for _, recipeProductSubtable in pairs(recipeList) do
		for _, recipeGradeSubtable in pairs(recipeProductSubtable) do
			for _, recipe in pairs(recipeGradeSubtable) do
				for buildingCount, buildingName in ipairs(recipe[INDEX_RECIPE_BUILDINGS_ARRAY]) do

					local rowText = '\n*<span class="nowrap">'

					if requiredProduct then
						-- When queried by product, show the building name
						rowText = rowText .. frame:expandTemplate{
							title = VIEW_BUILDING_LINK,
							args = {
								["name"] = buildingName,
								["iconsize"] = "none",
							},
						}
					else
						-- If past the first building and we're not showing building names, then this will create duplicate entries.
						if buildingCount > 1 then
							break
						end

						-- When queried by building or ingredient, show the product name
						if recipe[INDEX_RECIPE_IS_SERVICE] then
							rowText = rowText .. frame:expandTemplate{
								title = TEMPLATE_SERVICE_LINK,
								args = {
									["name"] = recipe[INDEX_RECIPE_PRODUCT_NAME],
									["size"] = "small",
								}
							}
						else
							rowText = rowText .. frame:expandTemplate{
								title = VIEW_RESOURCE_LINK,
								args = {
									["name"] = GoodsData.getName(recipe[INDEX_RECIPE_PRODUCT_NAME]),
									["iconfilename"] = GoodsData.getIcon(recipe[INDEX_RECIPE_PRODUCT_NAME]),
									["iconsize"] = "small",
								},
							}
						end
					end

					rowText = rowText .. "&nbsp;("
					rowText = rowText .. frame:expandTemplate{
						title = VIEW_GRADES[recipe[INDEX_RECIPE_GRADE]],
						args = {},
					}

					rowText = rowText .. ")</span>"

					ret = ret .. rowText
				end
			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 isService boolean true if this recipe offers a service instead of a product
---@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, isService, 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 type(isService) ~= "boolean" then
		error("Cannot construct new Recipe with an invalid service flag")
	end
	instance[INDEX_RECIPE_IS_SERVICE] = isService

	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

---main
---Called from Template:Recipe. Returns markup text for display by using external view templates.
---
---@param frame table the Mediawiki calling context for the template
---@return string wiki markup
function RecipeController.main(frame)

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

	--Unset blanks back to nil
	if requiredProduct == "" then
		requiredProduct = nil
	end
	if requiredBuilding == "" then
		requiredBuilding = nil
	end
	if requiredIngredient == "" then
		requiredIngredient = nil
	end

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

	if displayOverride == ARG_DISPLAY_OVERRIDE_LIST then
		return renderListView(frame, recipeList, requiredProduct, requiredBuilding, requiredIngredient)
	end

	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,
			["numrecipes"] = numRecipes,
		}
	}

	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