Module:BaseDataModel: Difference between revisions

From Against the Storm Official Wiki
m (Updating for service buildings)
(Adding method to get product + ingredient combos)
 
(9 intermediate revisions by the same user not shown)
Line 24: Line 24:
local INDEX_RECIPES = "recipes"
local INDEX_RECIPES = "recipes"
local INDEX_RECIPE_GRADE = "grade"
local INDEX_RECIPE_GRADE = "grade"
local INDEX_RECIPE_GRADE_ALT = "gradeId"
local INDEX_RECIPE_INGREDIENTS = "ingredients"
local INDEX_RECIPE_INGREDIENTS = "ingredients"
local INDEX_RECIPE_INGREDIENT_OPTION_AMOUNT = "amount"
local INDEX_RECIPE_INGREDIENT_OPTION_AMOUNT = "amount"
Line 43: Line 44:
local INDEX_SIZE_Y = "sizeY"
local INDEX_SIZE_Y = "sizeY"
local INDEX_STORAGE_CAP = "storage"
local INDEX_STORAGE_CAP = "storage"
local INDEX_STORAGE_TANK = "baseTankCapacity"
local INDEX_WORKPLACES = "workplaces"
local INDEX_WORKPLACES = "workplaces"


Line 296: Line 298:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
return building[INDEX_STORAGE_CAP]
if building[INDEX_STORAGE_CAP] then
return building[INDEX_STORAGE_CAP]
elseif building[INDEX_STORAGE_TANK] then
return building[INDEX_STORAGE_TANK]
end
else
else
return nil
return nil
Line 355: Line 361:
for _, recipe in ipairs(building[INDEX_RECIPES]) do
for _, recipe in ipairs(building[INDEX_RECIPES]) do


-- If there's a product subtable with an amount:
-- If there's a product subtable with an amount.
if recipe[INDEX_RECIPE_PRODUCT] then
if recipe[INDEX_RECIPE_PRODUCT] then
if recipe[INDEX_RECIPE_PRODUCT][INDEX_RECIPE_PRODUCT_ID] == productID then
if recipe[INDEX_RECIPE_PRODUCT][INDEX_RECIPE_PRODUCT_ID] == productID then
Line 364: Line 370:
end
end


-- There may also seeked deposits.
-- There may also be seeked deposits.
elseif recipe[INDEX_RECIPE_PRO_DEPOSITS] then
elseif recipe[INDEX_RECIPE_PRO_DEPOSITS] then
if recipe[INDEX_RECIPE_PRO_DEPOSITS] == productID then
if recipe[INDEX_RECIPE_PRO_DEPOSITS] == productID then
Line 433: Line 439:
ingredientsList = recipe[INDEX_RECIPE_INGREDIENTS]
ingredientsList = recipe[INDEX_RECIPE_INGREDIENTS]
elseif recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
elseif recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
ingredientsList = recipe[INDEX_RECIPE_ING_SERVICE_GOODS]
ingredientsList = { recipe[INDEX_RECIPE_ING_SERVICE_GOODS] }
end
end


Line 448: Line 454:
end
end


end
end
return ret
end
function BaseDataModel:getIDsAndRecipesWhereProductIDAndIngredientID(productID, ingredientID)
if not self.dataTable then
error(ERROR_MESSAGE_INSTANCE_NOT_INITIALIZED)
end
local ret = {}
for id, building in pairs(self.dataTable) do
for _, recipe in ipairs(building[INDEX_RECIPES]) do
-- If there's a product subtable with an amount.
if recipe[INDEX_RECIPE_PRODUCT] then
if recipe[INDEX_RECIPE_PRODUCT][INDEX_RECIPE_PRODUCT_ID] == productID then
-- Products have ingredients lists.
if recipe[INDEX_RECIPE_INGREDIENTS] then
for _, ingredientSlot in ipairs(recipe[INDEX_RECIPE_INGREDIENTS]) do
for _, option in ipairs(ingredientSlot) do
if option[INDEX_RECIPE_INGREDIENT_OPTION_ID] == ingredientID then
table.insert(ret, {
["buildingID"] = id,
["recipe"] = recipe,
})
break
end
end
end
end
end
-- There may also be seeked deposits; these never have ingredients, so skip.
elseif recipe[INDEX_RECIPE_PRO_DEPOSITS] then
-- skip, but I want this to have the same structure as other methods.
-- Or a service provided
elseif recipe[INDEX_RECIPE_PRO_SERVICE] then
if recipe[INDEX_RECIPE_PRO_SERVICE] == productID then
-- Services have service goods.
if recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
for _, ingredientSlot in ipairs(recipe[INDEX_RECIPE_ING_SERVICE_GOODS]) do
for _, option in ipairs(ingredientSlot) do
if option[INDEX_RECIPE_INGREDIENT_OPTION_ID] == ingredientID then
table.insert(ret, {
["buildingID"] = id,
["recipe"] = recipe,
})
end
end
end
end
end
end
end
end
end
end
Line 581: Line 643:
return recipeData.recipe[INDEX_RECIPE_PRO_DEPOSITS]
return recipeData.recipe[INDEX_RECIPE_PRO_DEPOSITS]


elseif recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
elseif recipeData.recipe[INDEX_RECIPE_PRO_SERVICE] then
return recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS]
return recipeData.recipe[INDEX_RECIPE_PRO_SERVICE]


else
else
Line 602: Line 664:
else
else
return 1
return 1
end
end
---isRecipeProvidingService
---Returns true if the provided recipe data table is providing a service
---
---@param recipeData table a recipe pair retrieved from one of the getters above
---@return boolean true if service, false if product
function BaseDataModel.isRecipeProvidingService(recipeData)
if recipeData.recipe[INDEX_RECIPE_PRO_SERVICE] then
return true
else
return false
end
end
end
end
Line 611: Line 686:
---@return number efficiency grade
---@return number efficiency grade
function BaseDataModel.getRecipeGrade(recipeData)
function BaseDataModel.getRecipeGrade(recipeData)
return CONVERT_GRADE_TO_NUMBER[recipeData.recipe[INDEX_RECIPE_GRADE]]
 
if recipeData.recipe[INDEX_RECIPE_GRADE] then
return CONVERT_GRADE_TO_NUMBER[recipeData.recipe[INDEX_RECIPE_GRADE]]
 
elseif recipeData.recipe[INDEX_RECIPE_GRADE_ALT] then
return CONVERT_GRADE_TO_NUMBER[recipeData.recipe[INDEX_RECIPE_GRADE_ALT]]
end
end
end


Line 676: Line 757:
---@return number of options for that ingredient slot
---@return number of options for that ingredient slot
function BaseDataModel.getRecipeIngredientNumOptions(recipeData, i)
function BaseDataModel.getRecipeIngredientNumOptions(recipeData, i)
return #recipeData.recipe[INDEX_RECIPE_INGREDIENTS][i]
 
if recipeData.recipe[INDEX_RECIPE_INGREDIENTS] then
return #recipeData.recipe[INDEX_RECIPE_INGREDIENTS][i]
 
elseif recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
-- Should always be 1, but let's not assume
return #recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS]
 
else -- Any other cases have no options at all.
return 0
end
end
end


Line 689: Line 780:
---@return string good ID
---@return string good ID
function BaseDataModel.getRecipeIngredientOptionIDAt(recipeData, i, j)
function BaseDataModel.getRecipeIngredientOptionIDAt(recipeData, i, j)
local optionsList = recipeData.recipe[INDEX_RECIPE_INGREDIENTS][i]
 
return optionsList[j][INDEX_RECIPE_INGREDIENT_OPTION_ID]
if recipeData.recipe[INDEX_RECIPE_INGREDIENTS] then
local optionsList = recipeData.recipe[INDEX_RECIPE_INGREDIENTS][i]
return optionsList[j][INDEX_RECIPE_INGREDIENT_OPTION_ID]
 
elseif recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
return recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS][i][INDEX_RECIPE_INGREDIENT_OPTION_ID]
end
end
end


Line 701: Line 798:
---@param i number index of which ingredient slot
---@param i number index of which ingredient slot
---@param j table index of which option at that slot
---@param j table index of which option at that slot
---@return number ingerdient amount
---@return number ingredient amount
function BaseDataModel.getRecipeIngredientOptionAmountAt(recipeData, i, j)
function BaseDataModel.getRecipeIngredientOptionAmountAt(recipeData, i, j)
local optionsList = recipeData.recipe[INDEX_RECIPE_INGREDIENTS][i]
 
return optionsList[j][INDEX_RECIPE_INGREDIENT_OPTION_AMOUNT]
if recipeData.recipe[INDEX_RECIPE_INGREDIENTS] then
local optionsList = recipeData.recipe[INDEX_RECIPE_INGREDIENTS][i]
return optionsList[j][INDEX_RECIPE_INGREDIENT_OPTION_AMOUNT]
 
elseif recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
return recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS][i][INDEX_RECIPE_INGREDIENT_OPTION_AMOUNT]
end
end
end



Latest revision as of 20:57, 31 October 2024

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

--- @module BaseDataModel
local BaseDataModel = {}



--region Dependencies

local JsonUtils = require("Module:JsonUtils")

--endregion



--region Private constants

local INDEX_CATEGORY = "category"
local INDEX_CITY_SCORE = "cityScore"
local INDEX_CONSTRUCTION_TIME = "constructionTime"
local INDEX_DESCRIPTION = "description"
local INDEX_NAME = "displayName"
local INDEX_ID = "id"
local INDEX_IS_ESSENTIAL = "initiallyEssential"
local INDEX_IS_MOVABLE = "movable"
local INDEX_RECIPES = "recipes"
local INDEX_RECIPE_GRADE = "grade"
local INDEX_RECIPE_GRADE_ALT = "gradeId"
local INDEX_RECIPE_INGREDIENTS = "ingredients"
local INDEX_RECIPE_INGREDIENT_OPTION_AMOUNT = "amount"
local INDEX_RECIPE_INGREDIENT_OPTION_ID = "name"
local INDEX_RECIPE_ING_SERVICE_GOODS = "goods" -- alt to ingredients for service buildings
local INDEX_RECIPE_PRODUCT = "product"
local INDEX_RECIPE_PRODUCT_AMOUNT = "amount"
local INDEX_RECIPE_PRODUCT_ID = "name"
local INDEX_RECIPE_PRO_DEPOSITS = "seekedDeposits" -- alt to product for huts and camps
local INDEX_RECIPE_PRO_SERVICE = "servedNeed" -- alt to product for service buildings
local INDEX_RECIPE_PRODUCTION_TIME = "productionTime"
local INDEX_RECIPE_PLANTING_TIME = "plantingTime"
local INDEX_RECIPE_HARVESTING_TIME = "harvestingTime"
local INDEX_RECIPE_GATHERING_TIME = "gatheringTime"
local INDEX_CONSTRUCTION_GOODS = "requiredGoods"
local INDEX_CONSTRUCTION_GOODS_AMOUNT = "amount"
local INDEX_CONSTRUCTION_GOODS_ID = "name"
local INDEX_SIZE_X = "sizeX"
local INDEX_SIZE_Y = "sizeY"
local INDEX_STORAGE_CAP = "storage"
local INDEX_STORAGE_TANK = "baseTankCapacity"
local INDEX_WORKPLACES = "workplaces"

local CONVERT_GRADE_TO_NUMBER = {
	["Grade0"] = 0,
	["Grade1"] = 1,
	["Grade2"] = 2,
	["Grade3"] = 3,
}

local ERROR_MESSAGE_INSTANCE_NOT_INITIALIZED = "This instance's data table was never initialized. Please check how and where this method was called to diagnose the problem"

--endregion



--region Private member variables

--- Main data array, indexed by ID.
local dataTable

--- Lookup table to make searching by names instant.
local mapNamesToIDs

--endregion



--region Private methods

---load
---Loads the JSON data into the two structured member variables for data access.
---
---Benchmarking: ~0.015 seconds
---
---@param instance table an instance of BaseDataModel
---@param dataFile string filename of Json data to load into this instance
---@return table, table instance's data structures --when uncommented for debugging
local function load(instance, dataFile)

	-- Utility module loads the data from JSON.
	local rawTable = JsonUtils.convertJSONToLuaTable(dataFile)
	-- Lightweight (no deep copying) restructuring for faster look-ups.
	instance.dataTable = {}
	instance.mapNamesToIDs = {}
	for _, record in ipairs(rawTable) do
		local key = record[INDEX_ID]
		local name = record[INDEX_NAME]
		instance.dataTable[key] = record
		instance.mapNamesToIDs[name] = key
	end
	--return instance.dataTable, instance.mapNamesToIDs --uncomment when debugging
end

---findID
---called with self and an ID, gets the record with that ID from that instance
---@param instance table an instance of BaseDataModel
---@param id string the ID
---@return table the record with that ID from that instance, or nil if not found
local function findID(instance, id)

	if not instance.dataTable then
		error(ERROR_MESSAGE_INSTANCE_NOT_INITIALIZED)
	end

	return instance.dataTable[id]
end

---findName
---called with self and a name gets the record with that name from that instance
---@param instance table an instance of BaseDataModel
---@param name string the display name
---@return table the record with that name, or nil if not found
local function findName(instance, name)

	if not instance.dataTable or not instance.mapNamesToIDs then
		error(ERROR_MESSAGE_INSTANCE_NOT_INITIALIZED)
	end

	local id = instance.mapNamesToIDs[name]
	if not id then
		return nil
	end
	return instance.dataTable[id]
end

--endregion



--region Public building interface

-- All building data models implement this interface
-- getID(displayName)
-- getCategory(id)
-- getCityScore(id)
-- getConstructionCosts(id) -- returns { [goodName] = stack size }
-- getConstructionTime(id)
-- getDescription(id)
-- getIcon(id)
-- isMovable(id)
-- getName(id)
-- getNumberOfWorkplaces(id)
-- getSize(id) -- returns string "X x Y"
-- getStorage(id)

---getID finds the specified name in the instance's data and returns the associated ID
---@param name string the display name
---@return string the ID, or nil if not found
function BaseDataModel:getID(name)
	local building = findName(self, name)
	if building then
		return building[INDEX_ID]
	else
		return nil
	end
end

---getCategory from the instance's data using an ID
---@param id string the ID
---@return string the category, or nil if not found
function BaseDataModel:getCategory(id)
	local building = findID(self, id)
	if building then
		return building[INDEX_CATEGORY]
	else
		return nil
	end
end

---getCityScore from the instance's data using an ID
---@param id string the ID
---@return number the city score, or nil if not found
function BaseDataModel:getCityScore(id)
	local building = findID(self, id)
	if building then
		return building[INDEX_CITY_SCORE]
	else
		return nil
	end
end

---getConstructionCosts from the instance's data using an ID in the form of an array of short tables describing goods required for construction.
---	example = {
---		[1] = {
---			["amount"] = 5,
---			["name"] = "[Mat Processed] Planks", -- note that this is NOT a name, but an ID
---		},
---		[2] = {
---			["amount"] = 2,
---			["name"] = "[Mat Processed] Bricks", -- note that this is NOT a name, but an ID
---		},
---	}
---@param id string the ID
---@return table an array of tables with amount and ID
function BaseDataModel:getConstructionCosts(id)
	local building = findID(self, id)
	if building then
		return building[INDEX_CONSTRUCTION_GOODS]
	else
		return nil
	end
end

---getConstructionTime from the instance's data using an ID
---@param id string the ID
---@return number the number of seconds, or nil if not found
function BaseDataModel:getConstructionTime(id)
	local building = findID(self, id)
	if building then
		return building[INDEX_CONSTRUCTION_TIME]
	else
		return nil
	end
end

---getDescription from the instance's data using an ID
---@param id string the ID
---@return string the description including sprite markup, or nil if not found
function BaseDataModel:getDescription(id)
	local building = findID(self, id)
	if building then
		return building[INDEX_DESCRIPTION]
	else
		return nil
	end
end

---getIcon from the instance's data using an ID
---@param id string the ID
---@return string the icon filename, or nil if not found
function BaseDataModel:getIcon(id)
	local building = findID(self, id)
	if building then
		return id .. "_icon.png"
	else
		return nil
	end
end

---isMovable from the instance's data using an ID
---@param id string the ID
---@return boolean true if the building can be moved, false if not (or nil if not found)
function BaseDataModel:isMovable(id)
	local building = findID(self, id)
	if building then
		return building[INDEX_IS_MOVABLE]
	else
		return nil
	end
end

---getName from the instance's data using an ID
---@param id string the ID
---@return string the name, or nil if not found
function BaseDataModel:getName(id)
	local building = findID(self, id)
	if building then
		return building[INDEX_NAME]
	else
		return nil
	end
end

---getNumberOfWorkplaces from the instance's data using an ID
---@param id string the ID
---@return number how many worker slots, or nil if not found
function BaseDataModel:getNumberOfWorkplaces(id)
	local building = findID(self, id)
	if building then
		return building[INDEX_WORKPLACES]
	else
		return nil
	end
end

---getSize from the instance's data using an ID
---@param id string the ID
---@return string as x-by-y, or nil if not found
function BaseDataModel:getSize(id)
	local building = findID(self, id)
	if building then
		return building[INDEX_SIZE_X] .. " × " .. building[INDEX_SIZE_Y]
	else
		return nil
	end
end

---getStorage from the instance's data using an ID
---@param id string the ID
---@return number the storage capacity, or nil if not found
function BaseDataModel:getStorage(id)
	local building = findID(self, id)
	if building then
		if building[INDEX_STORAGE_CAP] then
			return building[INDEX_STORAGE_CAP]
		elseif building[INDEX_STORAGE_TANK] then
			return building[INDEX_STORAGE_TANK]
		end
	else
		return nil
	end
end

--endregion



--region Public building recipe query interface

-- All methods in the Building recipe query interface returns an array of pairs of building IDs and recipes, pulled straight from the JSON-based data table:
-- [1] = {
--		[1].buildingID = string  --building ID
--		[1].recipe = {
--			["product"] = {  --product info
--				["name"] = string  --product ID *NOT* name!
--				["amount"] = number
--			}
--			["grade"] = string  --like "Grade2"
--			["productionTime"] = number  --in seconds
--			["ingredients"] = {  --ingredient slots (between 0-3)
--				[1] = {  --option list (between 1-6)
--					[1] = {  --option info
--						["name"] = string  --ingredient ID *NOT* name
--						["amount"] = number
--					},
--					... --next option
--				},
--				...--next ingredient slot
--			}
--		}
-- }
-- getIDsAndRecipesWhereProductID(productID)
-- getIDsAndRecipesWhereBuildingID(buildingID)
-- getIDsAndRecipesWhereIngredientID(ingredientID)
-- getIDsAndRecipesWhereProductIDAndBuildingID(productID, buildingID)
-- getIDsAndRecipesWhereIngredientIDAndBuildingID(ingredientID, buildingID)

---getIDsAndRecipesWhereProductID
---Looks through all the instance's buildings' recipes for any that have the specified product, and returns an array of pairs of building IDs and recipes.
---
---Handles when workshops and gathering huts name their fields differently.
---
---Benchmarking: ~0.0001
---
---@param productID string the product
---@return table array of pairs of buildingIDs and recipes, or {} if none found
function BaseDataModel:getIDsAndRecipesWhereProductID(productID)

	if not self.dataTable then
		error(ERROR_MESSAGE_INSTANCE_NOT_INITIALIZED)
	end

	local ret = {}
	for id, building in pairs(self.dataTable) do
		for _, recipe in ipairs(building[INDEX_RECIPES]) do

			-- If there's a product subtable with an amount.
			if recipe[INDEX_RECIPE_PRODUCT] then
				if recipe[INDEX_RECIPE_PRODUCT][INDEX_RECIPE_PRODUCT_ID] == productID then
					table.insert(ret, {
						["buildingID"] = id,
						["recipe"] = recipe,
					})
				end

			-- There may also be seeked deposits.
			elseif recipe[INDEX_RECIPE_PRO_DEPOSITS] then
				if recipe[INDEX_RECIPE_PRO_DEPOSITS] == productID then
					table.insert(ret, {
						["buildingID"] = id,
						["recipe"] = recipe,
					})
				end

			-- Or a service provided
			elseif recipe[INDEX_RECIPE_PRO_SERVICE] then
				if recipe[INDEX_RECIPE_PRO_SERVICE] == productID then
					table.insert(ret, {
						["buildingID"] = id,
						["recipe"] = recipe,
					})
				end
			end
		end
	end
	return ret
end

---getIDsAndRecipesWhereBuildingID
---Loads the instance's building's recipes into an array of pairs of the same building ID and those recipes.
---
---Benchmarking: ~0.0000 seconds
---
---@param buildingID string the building
---@return table array of pairs of buildingIDs and recipes, or {} if none found
function BaseDataModel:getIDsAndRecipesWhereBuildingID(buildingID)

	local building = findID(self, buildingID)
	if not building then
		return {}
	end

	local ret = {}
	for _, recipe in ipairs(building[INDEX_RECIPES]) do
		table.insert(ret, {
			["buildingID"] = buildingID,
			["recipe"] = recipe,
		})
	end
	return ret
end

---getIDsAndRecipesWhereIngredientID
---Looks through all the instance's buildings' recipes to find any with the specified ingredients, and returns an array of pairs of building IDs and recipes.
---
---Benchmarking: ~0.0003 seconds
---
---@param ingredientID string the ingredient
---@return table array of pairs of buildingIDs and recipes, or {} if none found
function BaseDataModel:getIDsAndRecipesWhereIngredientID(ingredientID)

	if not self.dataTable then
		error(ERROR_MESSAGE_INSTANCE_NOT_INITIALIZED)
	end

	local ret = {}
	for id, building in pairs(self.dataTable) do
		for _, recipe in ipairs(building[INDEX_RECIPES]) do

			local ingredientsList = {}
			-- Some recipes don't have ingredients; skip them obviously.
			if recipe[INDEX_RECIPE_INGREDIENTS] then
				ingredientsList = recipe[INDEX_RECIPE_INGREDIENTS]
			elseif recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
				ingredientsList = { recipe[INDEX_RECIPE_ING_SERVICE_GOODS] }
			end

			for _, ingredientSlot in ipairs(ingredientsList) do
				for _, option in ipairs(ingredientSlot) do
					if option[INDEX_RECIPE_INGREDIENT_OPTION_ID] == ingredientID then
						table.insert(ret, {
							["buildingID"] = id,
							["recipe"] = recipe,
						})
						break
					end
				end
			end

		end
	end
	return ret
end

function BaseDataModel:getIDsAndRecipesWhereProductIDAndIngredientID(productID, ingredientID)

	if not self.dataTable then
		error(ERROR_MESSAGE_INSTANCE_NOT_INITIALIZED)
	end

	local ret = {}
	for id, building in pairs(self.dataTable) do
		for _, recipe in ipairs(building[INDEX_RECIPES]) do

			-- If there's a product subtable with an amount.
			if recipe[INDEX_RECIPE_PRODUCT] then
				if recipe[INDEX_RECIPE_PRODUCT][INDEX_RECIPE_PRODUCT_ID] == productID then
					-- Products have ingredients lists.
					if recipe[INDEX_RECIPE_INGREDIENTS] then
						for _, ingredientSlot in ipairs(recipe[INDEX_RECIPE_INGREDIENTS]) do
							for _, option in ipairs(ingredientSlot) do
								if option[INDEX_RECIPE_INGREDIENT_OPTION_ID] == ingredientID then
									table.insert(ret, {
										["buildingID"] = id,
										["recipe"] = recipe,
									})
									break
								end
							end
						end
					end
				end

				-- There may also be seeked deposits; these never have ingredients, so skip.
			elseif recipe[INDEX_RECIPE_PRO_DEPOSITS] then
				-- skip, but I want this to have the same structure as other methods.

				-- Or a service provided
			elseif recipe[INDEX_RECIPE_PRO_SERVICE] then
				if recipe[INDEX_RECIPE_PRO_SERVICE] == productID then
					-- Services have service goods.
					if recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
						for _, ingredientSlot in ipairs(recipe[INDEX_RECIPE_ING_SERVICE_GOODS]) do
							for _, option in ipairs(ingredientSlot) do
								if option[INDEX_RECIPE_INGREDIENT_OPTION_ID] == ingredientID then
									table.insert(ret, {
										["buildingID"] = id,
										["recipe"] = recipe,
									})
								end
							end
						end
					end
				end
			end
		end
	end
	return ret
end

---getIDsAndRecipesWhereProductIDAndBuildingID
---Looks through the instance's specified buildings' recipes to see whether one has the specified product, and returns an array of pairs of building IDs and recipes.
---
---Benchmarking: ~0.0000 seconds
---
---@param productID string the product
---@param buildingID string the building
---@return table array of pairs of buildingIDs and recipes, or {} if none found
function BaseDataModel:getIDsAndRecipesWhereProductIDAndBuildingID(productID, buildingID)

	local building = findID(self, buildingID)
	if not building then
		return {}
	end

	local ret = {}
	for _, recipe in ipairs(building[INDEX_RECIPES]) do

		-- If there's a product subtable with an amount.
		if recipe[INDEX_RECIPE_PRODUCT] then
			if recipe[INDEX_RECIPE_PRODUCT][INDEX_RECIPE_PRODUCT_ID] == productID then
				table.insert(ret, {
					["buildingID"] = buildingID,
					["recipe"] = recipe,
				})
				-- Only one ever per building, so return now
				return ret
			end

		-- There could also be a seeked deposit.
		elseif recipe[INDEX_RECIPE_PRO_DEPOSITS] then
			if recipe[INDEX_RECIPE_PRO_DEPOSITS] == productID then
				table.insert(ret, {
					["buildingID"] = buildingID,
					["recipe"] = recipe,
				})
				-- Only one ever per building, so return now
				return ret
			end

		-- Or a service provided.
		elseif recipe[INDEX_RECIPE_PRO_SERVICE] then
			if recipe[INDEX_RECIPE_PRO_SERVICE] == productID then
				table.insert(ret, {
					["buildingID"] = buildingID,
					["recipe"] = recipe,
				})
				-- Only one ever per building, so return now
				return ret
			end
		end
	end
	return {}
end

---getIDsAndRecipesWhereIngredientIDAndBuildingID
---Looks through the instance's specified building's recipes to see whether any have the specified ingredient, and returns an array of pairs of building IDs and recipes.
---
---Benchmarking: ~0.0000 seconds
---
---@param ingredientID string
---@param buildingID string
---@return table array of pairs of buildingIDs and recipes, or {} if none found
function BaseDataModel:getIDsAndRecipesWhereIngredientIDAndBuildingID(ingredientID, buildingID)

	local building = findID(self, buildingID)
	if not building then
		return {}
	end

	local ret = {}
	for _, recipe in ipairs(building[INDEX_RECIPES]) do

		local ingredientsList = {}
		-- Some recipes don't have any ingredients; skip those obviously.
		if recipe[INDEX_RECIPE_INGREDIENTS] then
			ingredientsList = recipe[INDEX_RECIPE_INGREDIENTS]
		elseif recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
			ingredientsList = recipe[INDEX_RECIPE_ING_SERVICE_GOODS]
		end

		for _, ingredientSlot in ipairs(ingredientsList) do
			for _, option in ipairs(ingredientSlot) do
				if option[INDEX_RECIPE_INGREDIENT_OPTION_ID] == ingredientID then
					table.insert(ret, {
						["buildingID"] = buildingID,
						["recipe"] = recipe,
					})
					break
				end
			end
		end
	end
	return ret
end

--endregion



--region Public recipe data retrieval interface

-- Methods in this interface take a recipe taken from this BaseDataModel and return specific information within. This module owns the access to the inner data.
-- Unlike the recipe query and building interfaces, these methods are NOT called on instances, but the static class name, BaseDataModel.
--
-- BaseDataModel.getRecipeProductID(recipeData)
-- BaseDataModel.getRecipeProductAmount(recipeData)
-- BaseDataModel.getRecipeGrade(recipeData)
-- BaseDataModel.getRecipeBuildingID(recipeData)
-- BaseDataModel.getRecipeTime(recipeData)
-- BaseDataModel.getRecipeNumIngredientSlots(recipeData)
-- BaseDataModel.getRecipeIngredientNumOptions(recipeData, i)
-- BaseDataModel.getRecipeIngredientOptionIDAt(recipeData, i, j)
-- BaseDataModel.getRecipeIngredientOptionAmountAt(recipeData, i, j)

---getRecipeProductID
---Extracts the product ID from the provided recipe data table.
---
---@param recipeData table a recipe pair retrieved from one of the getters above
---@return string its productID
function BaseDataModel.getRecipeProductID(recipeData)

	if recipeData.recipe[INDEX_RECIPE_PRODUCT] then
		return recipeData.recipe[INDEX_RECIPE_PRODUCT][INDEX_RECIPE_PRODUCT_ID]

	elseif recipeData.recipe[INDEX_RECIPE_PRO_DEPOSITS] then
		return recipeData.recipe[INDEX_RECIPE_PRO_DEPOSITS]

	elseif recipeData.recipe[INDEX_RECIPE_PRO_SERVICE] then
		return recipeData.recipe[INDEX_RECIPE_PRO_SERVICE]

	else
		return nil
	end
end

---getRecipeProductAmount
---Extracts the product amount from the provided recipe data table.

---@param recipeData table a recipe pair retrieved from one of the getters above
---@return number product amount
function BaseDataModel.getRecipeProductAmount(recipeData)

	if recipeData.recipe[INDEX_RECIPE_PRODUCT] then
		return recipeData.recipe[INDEX_RECIPE_PRODUCT][INDEX_RECIPE_PRODUCT_AMOUNT]

	-- Default, for deposits, from which gatherers always bring home 1 and for services, which always provide 1
	else
		return 1
	end
end

---isRecipeProvidingService
---Returns true if the provided recipe data table is providing a service
---
---@param recipeData table a recipe pair retrieved from one of the getters above
---@return boolean true if service, false if product
function BaseDataModel.isRecipeProvidingService(recipeData)
	if recipeData.recipe[INDEX_RECIPE_PRO_SERVICE] then
		return true
	else
		return false
	end
end

---getRecipeGrade
---Extracts the efficiency grade from the provided recipe data table.

---@param recipeData table a recipe pair retrieved from one of the getters above
---@return number efficiency grade
function BaseDataModel.getRecipeGrade(recipeData)

	if recipeData.recipe[INDEX_RECIPE_GRADE] then
		return CONVERT_GRADE_TO_NUMBER[recipeData.recipe[INDEX_RECIPE_GRADE]]

	elseif recipeData.recipe[INDEX_RECIPE_GRADE_ALT] then
		return CONVERT_GRADE_TO_NUMBER[recipeData.recipe[INDEX_RECIPE_GRADE_ALT]]
	end
end

---getRecipeBuildingID
---Extracts the building ID from the provided recipe data table where this recipe was found.

---@param recipeData table a recipe pair retrieved from one of the getters above
---@return string ID of the building that makes this recipe
function BaseDataModel.getRecipeBuildingID(recipeData)
	return recipeData.buildingID
end

---getRecipeTime
---Extracts the production time from the provided recipe data table.
---
---Returns the production time for industry buildings, the sum of planting and harvesting time for farms, and gathering time for camps, etc.
---
---@param recipeData table a recipe pair retrieved from one of the getters above
---@return number in seconds
function BaseDataModel.getRecipeTime(recipeData)
	if recipeData.recipe[INDEX_RECIPE_PRODUCTION_TIME] then
		return recipeData.recipe[INDEX_RECIPE_PRODUCTION_TIME]

	elseif recipeData.recipe[INDEX_RECIPE_PLANTING_TIME] and recipeData.recipe[INDEX_RECIPE_HARVESTING_TIME] then
		return recipeData.recipe[INDEX_RECIPE_PLANTING_TIME]
				+ recipeData.recipe[INDEX_RECIPE_HARVESTING_TIME]

	elseif recipeData.recipe[INDEX_RECIPE_GATHERING_TIME] then
		return recipeData.recipe[INDEX_RECIPE_GATHERING_TIME]

	else -- for example, services don't have a time
		return 0
	end
end

---getRecipeNumIngredientSlots
---Counts the number of slots for ingredients found in the provided recipe data table.
---
---If the recipe has no ingredients (like for farms and camps), this safely returns zero.
---
---@param recipeData table a recipe pair retrieved from one of the getters above
---@return number of ingredients (slots of options)
function BaseDataModel.getRecipeNumIngredientSlots(recipeData)

	if recipeData.recipe[INDEX_RECIPE_INGREDIENTS] then
		return #recipeData.recipe[INDEX_RECIPE_INGREDIENTS]

	elseif recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
		return #recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS]

	--Camps, for example, don't have ingredients.
	else
		return 0
	end
end

---getRecipeIngredientNumOptions
---Counts the number of options for the specified ingredient slot in the provided recipe data table.
---
---This should never be called if the ingredients table doesn't exist.
---
---@param recipeData table a recipe pair retrieved from one of the getters above
---@param i number index of which ingredient slot
---@return number of options for that ingredient slot
function BaseDataModel.getRecipeIngredientNumOptions(recipeData, i)

	if recipeData.recipe[INDEX_RECIPE_INGREDIENTS] then
		return #recipeData.recipe[INDEX_RECIPE_INGREDIENTS][i]

	elseif recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
		-- Should always be 1, but let's not assume
		return #recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS]

	else -- Any other cases have no options at all.
		return 0
	end
end

---getRecipeIngredientOptionIDAt
---Extracts the good ID for the specified option for the specified ingredient slot in the provided recipe data table.
---
---This should never be called if the ingredients table doesn't exist.
---
---@param recipeData table a recipe pair retrieved from one of the getters above
---@param i number index of which ingredient slot
---@param j table index of which option at that slot
---@return string good ID
function BaseDataModel.getRecipeIngredientOptionIDAt(recipeData, i, j)

	if recipeData.recipe[INDEX_RECIPE_INGREDIENTS] then
		local optionsList = recipeData.recipe[INDEX_RECIPE_INGREDIENTS][i]
		return optionsList[j][INDEX_RECIPE_INGREDIENT_OPTION_ID]

	elseif recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
		return recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS][i][INDEX_RECIPE_INGREDIENT_OPTION_ID]
	end
end

---getRecipeIngredientOptionAmountAt
---Extracts the good ID for the specified option for the specified ingredient slot in the provided recipe data table.
---
---This should never be called if the ingredients table doesn't exist.
---
---@param recipeData table a recipe pair retrieved from one of the getters above
---@param i number index of which ingredient slot
---@param j table index of which option at that slot
---@return number ingredient amount
function BaseDataModel.getRecipeIngredientOptionAmountAt(recipeData, i, j)

	if recipeData.recipe[INDEX_RECIPE_INGREDIENTS] then
		local optionsList = recipeData.recipe[INDEX_RECIPE_INGREDIENTS][i]
		return optionsList[j][INDEX_RECIPE_INGREDIENT_OPTION_AMOUNT]

	elseif recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS] then
		return recipeData.recipe[INDEX_RECIPE_ING_SERVICE_GOODS][i][INDEX_RECIPE_INGREDIENT_OPTION_AMOUNT]
	end
end

--endregion



--region Public constructor

---new
---Constructs a new BaseDataModel by loading the specified data file into the instance.
---
---Benchmarking: ~0.0151 seconds
---
---@param dataFile string filename of Json data to load into this instance
function BaseDataModel.new(dataFile)

	local instance = {}
	setmetatable(instance, { __index = BaseDataModel })
	load(instance, dataFile)

	return instance
end

--endregion

return BaseDataModel