Module:IngredientsHierarchy: Difference between revisions
From Against the Storm Official Wiki
Testing |
making the hierarchy delving stop at fuel types |
||
| (19 intermediate revisions by the same user not shown) | |||
| Line 19: | Line 19: | ||
} | } | ||
local | local VIEW_RESOURCE_LINK = "Resource_link/view" | ||
--endregion | --endregion | ||
| Line 28: | Line 27: | ||
--region Private constants | --region Private constants | ||
local | local IS_FUEL_TYPE = { | ||
["[Mat Raw] Wood"] = true, | |||
["[Crafting] Oil"] = true, | |||
["[Crafting] Coal]"] = true, | |||
["[Crafting] Sea Marrow"] = true, | |||
} | |||
local | local PARAMETER_HEADER_ICON_SIZE = "large" | ||
local PARAMETER_HEADER_DISPLAY_MODE = "notext" | |||
local PARAMETER_NODE_ICON_SIZE = "medium" | |||
local PARAMETER_NODE_DISPLAY_MODE = "notext" | |||
local PARAMETER_LEAF_ICON_SIZE = "small" | |||
local PARAMETER_LEAF_DISPLAY_MODE = "notext" | |||
local PARAMETER_BEST_ICON_SIZE = "medium" | |||
local PARAMETER_BEST_DISPLAY_MODE = "notext" | |||
local CLASS_ENCOMPASSING_TABLE = "'wikitable mw-collapsible verticalaligntop'" | |||
local CLASS_HIERARCHY_TABLE = "wikitable" | |||
local CLASS_LEFTOVERS_TABLE = "wikitable" | |||
local BOLD = "'''" | |||
--endregion | |||
--region Localization string constants | |||
local ERROR_MESSAGE_INVALID_DEPTH = "You must specify the depth as a number that is greater than zero. Please see the template documentation for how to use the parameters" | |||
local ERROR_MESSAGE_INVALID_PRODUCT = "You must specify a valid good or service. Please see the template documentation for how to use the parameters. Good or service not found" | |||
local ERROR_MESSAGE_EMPTY = "You must specify at least one good or service. Please see the template documentation for how to use the parameters" | |||
local BEST_GOODS_HEADING = "Most important goods" | |||
--endregion | --endregion | ||
| Line 38: | Line 67: | ||
--region Private methods | --region Private methods | ||
---@private | |||
---Shifts the provided array items into keys with the value _true_ that can be used to check for existence in the provided array. **Not recursive.** Like this: | |||
--- | |||
---`local isPresentInArray = createPresenceArray(array)` | |||
--- | |||
---`if isPresentInArray[value] then` | |||
--- | |||
---@param mainArray table array to copy | |||
---@return table a new array, shifted to allow presence checks | |||
local function createPresenceArray(mainArray) | |||
local seen = {} | |||
for _, item in ipairs(mainArray) do | |||
seen[item] = true | |||
end | |||
return seen | |||
end | |||
---@private | ---@private | ||
| Line 55: | Line 103: | ||
--Create a set for fast lookup of items already in mainArray | --Create a set for fast lookup of items already in mainArray | ||
local | local isPresentInArray = createPresenceArray(mainArray) | ||
--Append only unique items from arrayToAdd | --Append only unique items from arrayToAdd | ||
for _, item in ipairs(arrayToAdd) do | for _, item in ipairs(arrayToAdd) do | ||
if not | if not isPresentInArray[item] then | ||
table.insert(mainArray, item) | table.insert(mainArray, item) | ||
isPresentInArray[item] = true -- Mark as seen so we don't add it again | |||
end | end | ||
end | end | ||
| Line 88: | Line 133: | ||
for i = 1, DataModel:getRecipeNumIngredientSlots(recipeData) do | for i = 1, DataModel:getRecipeNumIngredientSlots(recipeData) do | ||
for j = 1, DataModel:getRecipeIngredientNumOptions(recipeData, i) do | for j = 1, DataModel:getRecipeIngredientNumOptions(recipeData, i) do | ||
local ingredientID = DataModel:getRecipeIngredientOptionIDAt(recipeData, i, j) | local ingredientID = DataModel:getRecipeIngredientOptionIDAt(recipeData, i, j) | ||
table.insert(ingredientsList, ingredientID) | table.insert(ingredientsList, ingredientID) | ||
end | end | ||
| Line 99: | Line 144: | ||
function IngredientsHierarchy.makeHierarchy(rootID) | function IngredientsHierarchy.isAnyFuelInRecipe(DataModel, recipeData) | ||
if not recipeData then | |||
return false | |||
end | |||
for i = 1, DataModel:getRecipeNumIngredientSlots(recipeData) do | |||
if DataModel:isRecipeIngredientSlotFuel(recipeData, i) then | |||
return true | |||
end | |||
end | |||
return false | |||
end | |||
local function makeHierarchy(rootID) | |||
--Provide a quick way out if we find a no-ingredients recipe, like from a camp. | |||
local foundDeadEndRecipe = false | |||
--Or fuel ingredients | |||
local foundFuelIngredients = false | |||
--First go through all the data models and extract an array of ingredients, with no duplicates. | --First go through all the data models and extract an array of ingredients, with no duplicates. | ||
| Line 111: | Line 177: | ||
--Go through all of those recipes, still for just this data model, and pull out a flat list of ingredients. | --Go through all of those recipes, still for just this data model, and pull out a flat list of ingredients. | ||
for _, recipe in ipairs(recipeList) do | for _, recipe in ipairs(recipeList) do | ||
foundFuelIngredients = IngredientsHierarchy.isAnyFuelInRecipe(DataModel, recipe) | |||
local newList = getIngredientsList(DataModel, recipe) | local newList = getIngredientsList(DataModel, recipe) | ||
| Line 117: | Line 183: | ||
if #newList > 0 then | if #newList > 0 then | ||
ingredientsList = mergeArrays(ingredientsList, newList) | ingredientsList = mergeArrays(ingredientsList, newList) | ||
else | |||
--Quit looking for recipes if we found a dead-end (no-ingredients) recipe. | |||
foundDeadEndRecipe = true | |||
break | |||
end | end | ||
end | end | ||
| Line 123: | Line 193: | ||
--Recurse only if it's not a leaf node, but we have to wait to do this until we get through all the data models. | --Recurse only if it's not a leaf node, but we have to wait to do this until we get through all the data models. | ||
if #ingredientsList > 0 then | if #ingredientsList > 0 and not foundDeadEndRecipe then | ||
--We need to remove any ingredients that turn into tables, but keep them in order and in an array with no gaps. To do this, we'll create a new list to return and re-add subtables and string alike, in order. | --We need to remove any ingredients that turn into tables, but keep them in order and in an array with no gaps. To do this, we'll create a new list to return and re-add subtables and string alike, in order. | ||
local returnList = {} | local returnList = {} | ||
for _, newRoot in ipairs(ingredientsList) do | for _, newRoot in ipairs(ingredientsList) do | ||
local newNode = | --Skip making the sub-hierarchy for fuel types. Currently, no fuel type ingredients exist in a recipe as both the fuel and the thing being fueled, so this is a safe shortcut. | ||
local newNode | |||
if foundFuelIngredients and IS_FUEL_TYPE[newRoot] then | |||
--This just has to be set to not-a-table-type | |||
newNode = newRoot | |||
else | |||
newNode = makeHierarchy(newRoot) | |||
end | |||
if type(newNode) == "table" then | if type(newNode) == "table" then | ||
returnList[newRoot] = newNode | returnList[newRoot] = newNode | ||
| Line 149: | Line 226: | ||
---@param hierarchyTable table hierarchy of products and ingredients | ---@param hierarchyTable table hierarchy of products and ingredients | ||
---@param countingTable table (not needed on first call), the running tally | ---@param countingTable table (not needed on first call), the running tally | ||
function | local function countInstances(hierarchyTable, countingTable) | ||
--Only require the second parameter on recursion. | --Only require the second parameter on recursion. | ||
| Line 161: | Line 238: | ||
--We recurse for tables, but we still need to add their items to the total | --We recurse for tables, but we still need to add their items to the total | ||
if type(item) == "table" then | if type(item) == "table" then | ||
countInstances(item, countingTable) | |||
id = key | id = key | ||
else | else | ||
| Line 202: | Line 279: | ||
function | ---@private | ||
---Rolls up any items below the specified depth to an array at the specified depth. Non-destructive: returns a copy and does not modify the provided hierarchy. | |||
--- | |||
---@param hierarchyTable table hierarchy of products and ingredients | |||
---@param depth number the depth in the hierarchy to roll up to | |||
---@return table a new, rolled-up hierarchy table | |||
local function reduceBelowDepth(hierarchyTable, depth) | |||
--Leaf node, return immediately. | |||
if type(hierarchyTable) == "string" then | |||
return hierarchyTable | |||
end | |||
local reducedTable = {} | local reducedTable = {} | ||
if depth == 0 then | if depth == 0 then | ||
--This, conveniently, flattens the table, we just have to extract the names. | --This, conveniently, flattens the table, we just have to extract the names. | ||
countingTable = | countingTable = countInstances(hierarchyTable) | ||
for id, _ in pairs(countingTable) do | for id, _ in pairs(countingTable) do | ||
table.insert(reducedTable, id) | table.insert(reducedTable, id) | ||
| Line 213: | Line 301: | ||
else | else | ||
--Recurse down each subtable | --Recurse down each subtable | ||
for | for key, item in pairs(hierarchyTable) do | ||
if type(item) == "table" then | if type(item) == "table" then | ||
reducedTable[key] = reduceBelowDepth(item, depth - 1) | |||
else | else | ||
table.insert(reducedTable, item) | table.insert(reducedTable, item) | ||
| Line 225: | Line 313: | ||
end | end | ||
-- | |||
---@private | |||
---Gets all the items at the specified depth of the specified table. | |||
--- | |||
---@param hierarchyTable table hierarchy of products and ingredients | |||
---@param depth number the depth in the hierarchy to extract from | |||
---@return table array of items found in the hierarchy at that depth | |||
local function extractFromDepth(hierarchyTable, depth) | |||
local extractedItems = {} | |||
--Merge nodes at specified depth or if we get to strings first. | |||
if depth == 0 then | |||
for key, item in pairs(hierarchyTable) do | |||
if type(item) == "table" then | |||
extractedItems = mergeArrays(extractedItems, {key}) | |||
else | |||
extractedItems = mergeArrays(extractedItems, {item}) | |||
end | |||
end | |||
return extractedItems | |||
end | |||
for _, item in pairs(hierarchyTable) do | |||
--Skip any leaf nodes until we recurse to depth 0 | |||
if type(item) == "table" then | |||
extractedItems = mergeArrays(extractedItems, extractFromDepth(item, depth-1)) | |||
end | |||
end | |||
return extractedItems | |||
end | |||
---@private | |||
---Cuts off the provided table at the specified depth. Any keys to subtables at that level are converted into flat items, with no contents of their subtable preserved. Non-destructive: returns a copy and does not modify the provided hierarchy. | |||
--- | |||
---@param hierarchyTable table hierarchy of products and ingredients | |||
---@param depth number the depth in the hierarchy to extract from | |||
---@return table the truncated hierarchy | |||
local function truncateAtDepth(hierarchyTable, depth) | |||
local truncatedTable = {} | |||
if depth == 0 then | |||
for key, item in pairs(hierarchyTable) do | |||
--At depth, convert any tables to regular strings and delete their children. | |||
if type(item) == "table" then | |||
table.insert(truncatedTable, key) | |||
else | |||
table.insert(truncatedTable, item) | |||
end | |||
end | |||
return truncatedTable | |||
end | |||
for key, item in pairs(hierarchyTable) do | |||
if type(item) == "table" then | |||
truncatedTable[key] = truncateAtDepth(item, depth-1) | |||
else | |||
table.insert(truncatedTable, item) | |||
end | |||
end | |||
return truncatedTable | |||
end | |||
local function simplifyHierarchy(hierarchyTable, isAlreadyPresent) | |||
local simplifiedHierarchy = {} | |||
--Leaf nodes. | |||
if type(hierarchyTable) ~= "table" then | |||
if isAlreadyPresent[hierarchyTable] then | |||
return nil | |||
else | |||
return hierarchyTable | |||
end | |||
end | |||
for key, item in pairs(hierarchyTable) do | |||
if type(item) == "table" then | |||
if not isAlreadyPresent[key] then | |||
--If it is already present, then skipping it is sufficient. | |||
simplifiedHierarchy[key] = simplifyHierarchy(item, isAlreadyPresent) | |||
end | |||
else | |||
if not isAlreadyPresent[item] then | |||
table.insert(simplifiedHierarchy, item) | |||
end | |||
end | |||
end | |||
return simplifiedHierarchy | |||
end | |||
local function renderResourceLink(frame, goodID, iconSize, displayMode) | |||
local iconFilename = GoodsData.getIcon(goodID) | |||
local name = GoodsData.getName(goodID) | |||
local viewParameters = { | |||
["name"] = name, | |||
["iconsize"] = iconSize, | |||
["iconfilename"] = iconFilename, | |||
["display"] = displayMode, | |||
} | |||
return "" .. frame:expandTemplate{ | |||
title = VIEW_RESOURCE_LINK, | |||
args = viewParameters, | |||
} | |||
end | |||
function IngredientsHierarchy.renderHeaderRow(frame, cutoffDepth, goodID) | |||
--Table row markup must always start on a newline, so ensure it does. | |||
local ret = "\n|-" .. | |||
"\n!colspan=" .. cutoffDepth .. "|" | |||
return ret .. renderResourceLink(frame, goodID, PARAMETER_HEADER_ICON_SIZE, PARAMETER_HEADER_DISPLAY_MODE) | |||
end | |||
function IngredientsHierarchy.renderRowWithResourceID(frame, goodID, level) | |||
--Table row markup must always start on a newline, so ensure it does. | |||
local ret = "\n|-" .. | |||
"\n| " | |||
--Put some blank table cells before the link, to indent the ingredient. | |||
for _ = 1, level do | |||
ret = ret .. "|| " | |||
end | |||
return ret .. renderResourceLink(frame, goodID, PARAMETER_NODE_ICON_SIZE, PARAMETER_NODE_DISPLAY_MODE) | |||
end | |||
function IngredientsHierarchy.renderHierarchy(frame, hierarchyTable, level) | |||
if not level then | |||
level = 0 | |||
end | |||
local ret = "" | |||
for key, item in pairs(hierarchyTable) do | |||
if type(item) == "table" then | |||
ret = ret .. IngredientsHierarchy.renderRowWithResourceID(frame, key, level) | |||
ret = ret .. IngredientsHierarchy.renderHierarchy(frame, item, level + 1) | |||
else | |||
--String nodes | |||
ret = ret .. (IngredientsHierarchy.renderRowWithResourceID(frame, item, level)) | |||
end | |||
end | |||
return ret | |||
end | |||
function IngredientsHierarchy.renderProductTable(frame, cutoffDepth, caption, productIDList, hierarchyTable, leftoverItemsArray, sortedCountingTable) | |||
local ret = "{|class=" .. CLASS_ENCOMPASSING_TABLE .. | |||
"\n|+ " .. caption | |||
for _, id in ipairs(productIDList) do | |||
--Start a new cell of the encompassing table's header row | |||
local newCell = "\n| " | |||
--Enclose the entire product hierarchy table in that cell. | |||
newCell = newCell .. | |||
"\n{|class=" .. CLASS_HIERARCHY_TABLE .. | |||
IngredientsHierarchy.renderHeaderRow(frame, cutoffDepth, id) .. | |||
IngredientsHierarchy.renderHierarchy(frame, hierarchyTable[id]) .. | |||
"\n|}" | |||
ret = ret .. newCell | |||
end | |||
if leftoverItemsArray and #leftoverItemsArray > 0 then | |||
--New row in the encompassing table | |||
ret = ret .. "\n|-" .. | |||
"\n|colspan=" .. #productIDList .. "| " | |||
--Make the leftover ingredients list | |||
local list = "\n{|class=" .. CLASS_LEFTOVERS_TABLE .. | |||
"\n| " | |||
for _, goodID in ipairs(leftoverItemsArray) do | |||
list = list .. "\n" .. renderResourceLink(frame, goodID, PARAMETER_LEAF_ICON_SIZE, PARAMETER_LEAF_DISPLAY_MODE) | |||
end | |||
list = list .. "\n|}" | |||
ret = ret .. list | |||
end | |||
if sortedCountingTable and #sortedCountingTable > 0 then | |||
local best = "\n|-" .. | |||
"\n{|class=" .. CLASS_LEFTOVERS_TABLE | |||
.. "\n! " .. BEST_GOODS_HEADING .. ": " | |||
local cutoffCount = sortedCountingTable[1].count / 2 | |||
for _, pair in ipairs(sortedCountingTable) do | |||
--Quit once we get below the cutoff | |||
if pair.count < cutoffCount then | |||
break | |||
end | |||
best = best .. "\n| " .. BOLD .. pair.count .. BOLD .. " " .. renderResourceLink(frame, pair.id, PARAMETER_BEST_ICON_SIZE, PARAMETER_BEST_DISPLAY_MODE) | |||
end | |||
best = best .. "\n|}" | |||
ret = ret .. best | |||
end | |||
return ret .. "\n|}" | |||
end | |||
function IngredientsHierarchy.trim(str) | |||
--Ignore second returned value from gsub. | |||
str = str:gsub("^%s*(.-)%s*$", "%1") | |||
return str | |||
end | |||
-- | --endregion | ||
--region Public methods | |||
function IngredientsHierarchy.main(frame) | function IngredientsHierarchy.main(frame) | ||
--Validate the depth | |||
local cutoffDepth = tonumber(frame.args.depth) | |||
if not cutoffDepth or type(cutoffDepth) ~= "number" or cutoffDepth < 0 then | |||
error(ERROR_MESSAGE_INVALID_DEPTH) | |||
end | |||
--Increment, so that depth of 1 doesn't flatten even the top-level products | |||
cutoffDepth = cutoffDepth + 1 | |||
--Validate the products | |||
local productIDList = {} | local productIDList = {} | ||
-- | --Using ipairs will skip over the named parameters for depth and caption. | ||
for _, productName in ipairs(frame.args) do | for _, productName in ipairs(frame.args) do | ||
local productID = GoodsData.getGoodID(productName) | --Trim any surrounding whitespace from the name | ||
productName = IngredientsHierarchy.trim(productName) | |||
--Skip any blank parameters | |||
if productName and productName ~= "" then | |||
--Convert the parameters to IDs instead of names. | |||
local productID = GoodsData.getGoodID(productName) | |||
if not productID then | |||
--Swap out names of services for the goods, since we're focusing on the products themselves; this also has the nice side-effect of reducing the level of the hierarchy by one where it doesn't matter much. | |||
productID = ServicesData.getServiceGoodID(productName) | |||
end | |||
if not productID then | |||
error(ERROR_MESSAGE_INVALID_PRODUCT .. ": " .. productName) | |||
end | |||
table.insert(productIDList, productID) | |||
end | end | ||
end | |||
if #productIDList < 1 then | |||
error(ERROR_MESSAGE_EMPTY) | |||
end | end | ||
--Build the hierarchy and the total counts. | |||
local totalHierarchy = {} | local totalHierarchy = {} | ||
for _, id in ipairs(productIDList) do | for _, id in ipairs(productIDList) do | ||
totalHierarchy[id] = makeHierarchy(id) | totalHierarchy[id] = makeHierarchy(id) | ||
end | end | ||
local sortedCountingTable = countAndSort(totalHierarchy) | local sortedCountingTable = countAndSort(totalHierarchy) | ||
--Split the hierarchy at the specified depth into a separate flat list. | |||
local rolledupHierarchy = reduceBelowDepth(totalHierarchy, cutoffDepth) | |||
local leftoverItemsArray = extractFromDepth(rolledupHierarchy, cutoffDepth) | |||
local truncatedHierarchy = truncateAtDepth(rolledupHierarchy, cutoffDepth-1) | |||
local simplifiedHierarchy = simplifyHierarchy(truncatedHierarchy, createPresenceArray(leftoverItemsArray)) | |||
local caption = frame.args.caption or "" | |||
return IngredientsHierarchy.renderProductTable(frame, cutoffDepth, caption, productIDList, truncatedHierarchy, leftoverItemsArray, sortedCountingTable) | |||
end | |||
return IngredientsHierarchy | return IngredientsHierarchy | ||
Latest revision as of 03:41, 13 November 2024
Documentation for this module may be created at Module:IngredientsHierarchy/doc
---@module IngredientsHierarchy
local IngredientsHierarchy = {}
--region Dependencies
local GoodsData = require("Module:GoodsData")
local ServicesData = require("Module:ServicesData")
local DataModelsWithRecipes = {
require("Module:CampsData"),
require("Module:FarmsData"),
require("Module:FishingData"),
require("Module:GatheringData"),
require("Module:InstitutionsData"),
require("Module:RainCollectorsData"),
require("Module:WorkshopsData"),
}
local VIEW_RESOURCE_LINK = "Resource_link/view"
--endregion
--region Private constants
local IS_FUEL_TYPE = {
["[Mat Raw] Wood"] = true,
["[Crafting] Oil"] = true,
["[Crafting] Coal]"] = true,
["[Crafting] Sea Marrow"] = true,
}
local PARAMETER_HEADER_ICON_SIZE = "large"
local PARAMETER_HEADER_DISPLAY_MODE = "notext"
local PARAMETER_NODE_ICON_SIZE = "medium"
local PARAMETER_NODE_DISPLAY_MODE = "notext"
local PARAMETER_LEAF_ICON_SIZE = "small"
local PARAMETER_LEAF_DISPLAY_MODE = "notext"
local PARAMETER_BEST_ICON_SIZE = "medium"
local PARAMETER_BEST_DISPLAY_MODE = "notext"
local CLASS_ENCOMPASSING_TABLE = "'wikitable mw-collapsible verticalaligntop'"
local CLASS_HIERARCHY_TABLE = "wikitable"
local CLASS_LEFTOVERS_TABLE = "wikitable"
local BOLD = "'''"
--endregion
--region Localization string constants
local ERROR_MESSAGE_INVALID_DEPTH = "You must specify the depth as a number that is greater than zero. Please see the template documentation for how to use the parameters"
local ERROR_MESSAGE_INVALID_PRODUCT = "You must specify a valid good or service. Please see the template documentation for how to use the parameters. Good or service not found"
local ERROR_MESSAGE_EMPTY = "You must specify at least one good or service. Please see the template documentation for how to use the parameters"
local BEST_GOODS_HEADING = "Most important goods"
--endregion
--region Private methods
---@private
---Shifts the provided array items into keys with the value _true_ that can be used to check for existence in the provided array. **Not recursive.** Like this:
---
---`local isPresentInArray = createPresenceArray(array)`
---
---`if isPresentInArray[value] then`
---
---@param mainArray table array to copy
---@return table a new array, shifted to allow presence checks
local function createPresenceArray(mainArray)
local seen = {}
for _, item in ipairs(mainArray) do
seen[item] = true
end
return seen
end
---@private
---Generic table utility to merge two arrays into the first, ensuring uniqueness.
---
---@param mainArray table first array
---@param arrayToAdd table array to add to the first
local function mergeArrays(mainArray, arrayToAdd)
--Quickly return from edge cases
if not arrayToAdd or #arrayToAdd < 1 then
return mainArray
end
if not mainArray or #mainArray < 1 then
return arrayToAdd
end
--Create a set for fast lookup of items already in mainArray
local isPresentInArray = createPresenceArray(mainArray)
--Append only unique items from arrayToAdd
for _, item in ipairs(arrayToAdd) do
if not isPresentInArray[item] then
table.insert(mainArray, item)
isPresentInArray[item] = true -- Mark as seen so we don't add it again
end
end
return mainArray
end
---@private
---Flattens the provided recipe data into just its ingredient IDs.
---
---@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 array of ingredient IDs
local function getIngredientsList(DataModel, recipeData)
if not recipeData then
return {}
end
local ingredientsList = {}
for i = 1, DataModel:getRecipeNumIngredientSlots(recipeData) do
for j = 1, DataModel:getRecipeIngredientNumOptions(recipeData, i) do
local ingredientID = DataModel:getRecipeIngredientOptionIDAt(recipeData, i, j)
table.insert(ingredientsList, ingredientID)
end
end
return ingredientsList
end
function IngredientsHierarchy.isAnyFuelInRecipe(DataModel, recipeData)
if not recipeData then
return false
end
for i = 1, DataModel:getRecipeNumIngredientSlots(recipeData) do
if DataModel:isRecipeIngredientSlotFuel(recipeData, i) then
return true
end
end
return false
end
local function makeHierarchy(rootID)
--Provide a quick way out if we find a no-ingredients recipe, like from a camp.
local foundDeadEndRecipe = false
--Or fuel ingredients
local foundFuelIngredients = false
--First go through all the data models and extract an array of ingredients, with no duplicates.
local ingredientsList = {}
for _, DataModel in ipairs(DataModelsWithRecipes) do
local recipeList = DataModel:getIDsAndRecipesWhereProductID(rootID)
--There will always be at least one recipe, but we don't know whether any have ingredients yet.
if #recipeList > 0 then
--Go through all of those recipes, still for just this data model, and pull out a flat list of ingredients.
for _, recipe in ipairs(recipeList) do
foundFuelIngredients = IngredientsHierarchy.isAnyFuelInRecipe(DataModel, recipe)
local newList = getIngredientsList(DataModel, recipe)
--Check if it's a leaf node, if so, add to the running ingredients list
if #newList > 0 then
ingredientsList = mergeArrays(ingredientsList, newList)
else
--Quit looking for recipes if we found a dead-end (no-ingredients) recipe.
foundDeadEndRecipe = true
break
end
end
end
end
--Recurse only if it's not a leaf node, but we have to wait to do this until we get through all the data models.
if #ingredientsList > 0 and not foundDeadEndRecipe then
--We need to remove any ingredients that turn into tables, but keep them in order and in an array with no gaps. To do this, we'll create a new list to return and re-add subtables and string alike, in order.
local returnList = {}
for _, newRoot in ipairs(ingredientsList) do
--Skip making the sub-hierarchy for fuel types. Currently, no fuel type ingredients exist in a recipe as both the fuel and the thing being fueled, so this is a safe shortcut.
local newNode
if foundFuelIngredients and IS_FUEL_TYPE[newRoot] then
--This just has to be set to not-a-table-type
newNode = newRoot
else
newNode = makeHierarchy(newRoot)
end
if type(newNode) == "table" then
returnList[newRoot] = newNode
else
--When not a table, need to add the ingredient to the list we'll return
table.insert(returnList, newRoot)
end
end
return returnList
else
--No new ingredients, so just return the string itself
return rootID
end
end
---@private
---Runs through the provided table, counting how many times each entry appears in the hierarchy. The second parameter is cumulative, while descending the tree.
---
---@param hierarchyTable table hierarchy of products and ingredients
---@param countingTable table (not needed on first call), the running tally
local function countInstances(hierarchyTable, countingTable)
--Only require the second parameter on recursion.
if not countingTable then
countingTable = {}
end
for key, item in pairs(hierarchyTable) do
local id = ""
--We recurse for tables, but we still need to add their items to the total
if type(item) == "table" then
countInstances(item, countingTable)
id = key
else
id = item
end
if not countingTable[id] then
countingTable[id] = 1
else
countingTable[id] = countingTable[id] + 1
end
end
return countingTable
end
---@private
---Counts all the unique items in the provided table and sorts the counts. The result is a sorted array of pairs, where pair.id and pair.count store the ID of the good or service.
---
---@param hierarchyTable table hierarchy of products and ingredients
---@return table sorted array of pairs of IDs and counts
local function countAndSort(hierarchyTable)
local countingTable = countInstances(hierarchyTable)
local sortedCountingTable = {}
for ingredient, totalCount in pairs(countingTable) do
table.insert(sortedCountingTable, { id = ingredient, count = totalCount })
end
table.sort(sortedCountingTable, function(a, b)
return a.count > b.count
end)
return sortedCountingTable
end
---@private
---Rolls up any items below the specified depth to an array at the specified depth. Non-destructive: returns a copy and does not modify the provided hierarchy.
---
---@param hierarchyTable table hierarchy of products and ingredients
---@param depth number the depth in the hierarchy to roll up to
---@return table a new, rolled-up hierarchy table
local function reduceBelowDepth(hierarchyTable, depth)
--Leaf node, return immediately.
if type(hierarchyTable) == "string" then
return hierarchyTable
end
local reducedTable = {}
if depth == 0 then
--This, conveniently, flattens the table, we just have to extract the names.
countingTable = countInstances(hierarchyTable)
for id, _ in pairs(countingTable) do
table.insert(reducedTable, id)
end
else
--Recurse down each subtable
for key, item in pairs(hierarchyTable) do
if type(item) == "table" then
reducedTable[key] = reduceBelowDepth(item, depth - 1)
else
table.insert(reducedTable, item)
end
end
end
return reducedTable
end
---@private
---Gets all the items at the specified depth of the specified table.
---
---@param hierarchyTable table hierarchy of products and ingredients
---@param depth number the depth in the hierarchy to extract from
---@return table array of items found in the hierarchy at that depth
local function extractFromDepth(hierarchyTable, depth)
local extractedItems = {}
--Merge nodes at specified depth or if we get to strings first.
if depth == 0 then
for key, item in pairs(hierarchyTable) do
if type(item) == "table" then
extractedItems = mergeArrays(extractedItems, {key})
else
extractedItems = mergeArrays(extractedItems, {item})
end
end
return extractedItems
end
for _, item in pairs(hierarchyTable) do
--Skip any leaf nodes until we recurse to depth 0
if type(item) == "table" then
extractedItems = mergeArrays(extractedItems, extractFromDepth(item, depth-1))
end
end
return extractedItems
end
---@private
---Cuts off the provided table at the specified depth. Any keys to subtables at that level are converted into flat items, with no contents of their subtable preserved. Non-destructive: returns a copy and does not modify the provided hierarchy.
---
---@param hierarchyTable table hierarchy of products and ingredients
---@param depth number the depth in the hierarchy to extract from
---@return table the truncated hierarchy
local function truncateAtDepth(hierarchyTable, depth)
local truncatedTable = {}
if depth == 0 then
for key, item in pairs(hierarchyTable) do
--At depth, convert any tables to regular strings and delete their children.
if type(item) == "table" then
table.insert(truncatedTable, key)
else
table.insert(truncatedTable, item)
end
end
return truncatedTable
end
for key, item in pairs(hierarchyTable) do
if type(item) == "table" then
truncatedTable[key] = truncateAtDepth(item, depth-1)
else
table.insert(truncatedTable, item)
end
end
return truncatedTable
end
local function simplifyHierarchy(hierarchyTable, isAlreadyPresent)
local simplifiedHierarchy = {}
--Leaf nodes.
if type(hierarchyTable) ~= "table" then
if isAlreadyPresent[hierarchyTable] then
return nil
else
return hierarchyTable
end
end
for key, item in pairs(hierarchyTable) do
if type(item) == "table" then
if not isAlreadyPresent[key] then
--If it is already present, then skipping it is sufficient.
simplifiedHierarchy[key] = simplifyHierarchy(item, isAlreadyPresent)
end
else
if not isAlreadyPresent[item] then
table.insert(simplifiedHierarchy, item)
end
end
end
return simplifiedHierarchy
end
local function renderResourceLink(frame, goodID, iconSize, displayMode)
local iconFilename = GoodsData.getIcon(goodID)
local name = GoodsData.getName(goodID)
local viewParameters = {
["name"] = name,
["iconsize"] = iconSize,
["iconfilename"] = iconFilename,
["display"] = displayMode,
}
return "" .. frame:expandTemplate{
title = VIEW_RESOURCE_LINK,
args = viewParameters,
}
end
function IngredientsHierarchy.renderHeaderRow(frame, cutoffDepth, goodID)
--Table row markup must always start on a newline, so ensure it does.
local ret = "\n|-" ..
"\n!colspan=" .. cutoffDepth .. "|"
return ret .. renderResourceLink(frame, goodID, PARAMETER_HEADER_ICON_SIZE, PARAMETER_HEADER_DISPLAY_MODE)
end
function IngredientsHierarchy.renderRowWithResourceID(frame, goodID, level)
--Table row markup must always start on a newline, so ensure it does.
local ret = "\n|-" ..
"\n| "
--Put some blank table cells before the link, to indent the ingredient.
for _ = 1, level do
ret = ret .. "|| "
end
return ret .. renderResourceLink(frame, goodID, PARAMETER_NODE_ICON_SIZE, PARAMETER_NODE_DISPLAY_MODE)
end
function IngredientsHierarchy.renderHierarchy(frame, hierarchyTable, level)
if not level then
level = 0
end
local ret = ""
for key, item in pairs(hierarchyTable) do
if type(item) == "table" then
ret = ret .. IngredientsHierarchy.renderRowWithResourceID(frame, key, level)
ret = ret .. IngredientsHierarchy.renderHierarchy(frame, item, level + 1)
else
--String nodes
ret = ret .. (IngredientsHierarchy.renderRowWithResourceID(frame, item, level))
end
end
return ret
end
function IngredientsHierarchy.renderProductTable(frame, cutoffDepth, caption, productIDList, hierarchyTable, leftoverItemsArray, sortedCountingTable)
local ret = "{|class=" .. CLASS_ENCOMPASSING_TABLE ..
"\n|+ " .. caption
for _, id in ipairs(productIDList) do
--Start a new cell of the encompassing table's header row
local newCell = "\n| "
--Enclose the entire product hierarchy table in that cell.
newCell = newCell ..
"\n{|class=" .. CLASS_HIERARCHY_TABLE ..
IngredientsHierarchy.renderHeaderRow(frame, cutoffDepth, id) ..
IngredientsHierarchy.renderHierarchy(frame, hierarchyTable[id]) ..
"\n|}"
ret = ret .. newCell
end
if leftoverItemsArray and #leftoverItemsArray > 0 then
--New row in the encompassing table
ret = ret .. "\n|-" ..
"\n|colspan=" .. #productIDList .. "| "
--Make the leftover ingredients list
local list = "\n{|class=" .. CLASS_LEFTOVERS_TABLE ..
"\n| "
for _, goodID in ipairs(leftoverItemsArray) do
list = list .. "\n" .. renderResourceLink(frame, goodID, PARAMETER_LEAF_ICON_SIZE, PARAMETER_LEAF_DISPLAY_MODE)
end
list = list .. "\n|}"
ret = ret .. list
end
if sortedCountingTable and #sortedCountingTable > 0 then
local best = "\n|-" ..
"\n{|class=" .. CLASS_LEFTOVERS_TABLE
.. "\n! " .. BEST_GOODS_HEADING .. ": "
local cutoffCount = sortedCountingTable[1].count / 2
for _, pair in ipairs(sortedCountingTable) do
--Quit once we get below the cutoff
if pair.count < cutoffCount then
break
end
best = best .. "\n| " .. BOLD .. pair.count .. BOLD .. " " .. renderResourceLink(frame, pair.id, PARAMETER_BEST_ICON_SIZE, PARAMETER_BEST_DISPLAY_MODE)
end
best = best .. "\n|}"
ret = ret .. best
end
return ret .. "\n|}"
end
function IngredientsHierarchy.trim(str)
--Ignore second returned value from gsub.
str = str:gsub("^%s*(.-)%s*$", "%1")
return str
end
--endregion
--region Public methods
function IngredientsHierarchy.main(frame)
--Validate the depth
local cutoffDepth = tonumber(frame.args.depth)
if not cutoffDepth or type(cutoffDepth) ~= "number" or cutoffDepth < 0 then
error(ERROR_MESSAGE_INVALID_DEPTH)
end
--Increment, so that depth of 1 doesn't flatten even the top-level products
cutoffDepth = cutoffDepth + 1
--Validate the products
local productIDList = {}
--Using ipairs will skip over the named parameters for depth and caption.
for _, productName in ipairs(frame.args) do
--Trim any surrounding whitespace from the name
productName = IngredientsHierarchy.trim(productName)
--Skip any blank parameters
if productName and productName ~= "" then
--Convert the parameters to IDs instead of names.
local productID = GoodsData.getGoodID(productName)
if not productID then
--Swap out names of services for the goods, since we're focusing on the products themselves; this also has the nice side-effect of reducing the level of the hierarchy by one where it doesn't matter much.
productID = ServicesData.getServiceGoodID(productName)
end
if not productID then
error(ERROR_MESSAGE_INVALID_PRODUCT .. ": " .. productName)
end
table.insert(productIDList, productID)
end
end
if #productIDList < 1 then
error(ERROR_MESSAGE_EMPTY)
end
--Build the hierarchy and the total counts.
local totalHierarchy = {}
for _, id in ipairs(productIDList) do
totalHierarchy[id] = makeHierarchy(id)
end
local sortedCountingTable = countAndSort(totalHierarchy)
--Split the hierarchy at the specified depth into a separate flat list.
local rolledupHierarchy = reduceBelowDepth(totalHierarchy, cutoffDepth)
local leftoverItemsArray = extractFromDepth(rolledupHierarchy, cutoffDepth)
local truncatedHierarchy = truncateAtDepth(rolledupHierarchy, cutoffDepth-1)
local simplifiedHierarchy = simplifyHierarchy(truncatedHierarchy, createPresenceArray(leftoverItemsArray))
local caption = frame.args.caption or ""
return IngredientsHierarchy.renderProductTable(frame, cutoffDepth, caption, productIDList, truncatedHierarchy, leftoverItemsArray, sortedCountingTable)
end
return IngredientsHierarchy


