Module:BaseDataModel: Difference between revisions

From Against the Storm Official Wiki
(Refactoring and significantly improving luadoc; no functional changes.)
(Prepping for better module inheritance and robustness. no functional changes yet)
Line 26: Line 26:
---@field dataTable table Primary data table of _buildingRecordTables_, keyed by _buildingIDString_
---@field dataTable table Primary data table of _buildingRecordTables_, keyed by _buildingIDString_
---@field mapNamesToIDs table Lookup table, returns a _buildingIDString_ from a building's display name
---@field mapNamesToIDs table Lookup table, returns a _buildingIDString_ from a building's display name
---@field INDEX table data schema, with field names and subtables of field names
---@field schema table data schema, with field names and subtables of field names
local BaseDataModel = {}
local BaseDataModel = {}


Line 41: Line 41:
---@protected
---@protected
---Protected data schema defining Json field names for accessing data.
---Protected data schema defining Json field names for accessing data.
---@field INDEX table data schema, with field names and subtables of field names
---@field schema table data schema, with field names and subtables of field names
BaseDataModel.INDEX = {
BaseDataModel.schema = {
ID = "id",
ID = "id",
NAME = "displayName",
NAME = "displayName",
CATEGORY = "category",
CATEGORY1 = "category",
CITY_SCORE = "cityScore",
CITY_SCORE = "cityScore",
CONSTRUCTION_TIME = "constructionTime",
CONSTRUCTION_TIME = "constructionTime",
Line 84: Line 84:
}
}


local INDEX_NAME = "displayName"
---Secondary category, to be defined by derived classes.
local INDEX_CONSTRUCTION_TIME = "constructionTime"
BaseDataModel.schema.CATEGORY2 = "category2"
local INDEX_DESCRIPTION = "description"


local INDEX_IS_MOVABLE = "movable"
-- This section is deprecated.
local INDEX_RECIPES = "recipes"
local INDEX_RECIPES = "recipes"
local INDEX_RECIPE_GRADE = "grade"
local INDEX_RECIPE_GRADE = "grade"
Line 105: Line 104:
local INDEX_RECIPE_HARVESTING_TIME = "harvestingTime"
local INDEX_RECIPE_HARVESTING_TIME = "harvestingTime"
local INDEX_RECIPE_GATHERING_TIME = "gatheringTime"
local INDEX_RECIPE_GATHERING_TIME = "gatheringTime"
local INDEX_CONSTRUCTION_GOODS = "requiredGoods"
local INDEX_SIZE_X = "sizeX"
local INDEX_SIZE_Y = "sizeY"
local INDEX_STORAGE_CAP = "storage"
local INDEX_STORAGE_TANK = "baseTankCapacity"
local INDEX_STORAGE_TANK = "baseTankCapacity"
local INDEX_WORKPLACES = "workplaces"


--endregion
--endregion
Line 117: Line 111:


--region Private constants
--region Private constants
---Shortcut to BaseDataModel.INDEX
local INDEX = BaseDataModel.INDEX


---Enum converts grade strings (keys) to numbers (values).
---Enum converts grade strings (keys) to numbers (values).
Line 132: Line 122:


--endregion
--endregion


--region Localization string constants
--region Localization string constants
Line 164: Line 156:
instance.mapNamesToIDs = {}
instance.mapNamesToIDs = {}
for _, record in ipairs(rawTable) do
for _, record in ipairs(rawTable) do
local key = record[INDEX.ID]
local id = record[instance.schema.ID]
local name = record[INDEX.NAME]
local name = record[instance.schema.NAME]
instance.dataTable[key] = record
instance.dataTable[id] = record
instance.mapNamesToIDs[name] = key
instance.mapNamesToIDs[name] = id
 
-- If derived classes have anything to do per-record, let them here.
instance:initializeRecord(id)
end
end
--return instance.dataTable, instance.mapNamesToIDs --uncomment when debugging
--return instance.dataTable, instance.mapNamesToIDs --uncomment when debugging
Line 206: Line 201:
end
end
return instance.dataTable[id] or nil
return instance.dataTable[id] or nil
end
---Deeply copies the provided table and returns the copy. Does not modify the original.
---
---@param original table to copy
---@return table the copy
local function deepCopy(original)
local copy
if type(original) == "table" then
copy = {}
for key, value in pairs(original) do
copy[key] = deepCopy(value)
end
else
copy = original
end
return copy
end
--endregion
--region Public constructor
---@public
---@constructor
---Instantiates a new BaseDataModel by loading the specified data file into the instance.
---
---(Benchmarking: ~0.0170 seconds with largest data file.)
---
---@param dataFile string filename of Json data to load into this instance
---@return BaseDataModel a new instance storing the specified data
function BaseDataModel.new(dataFile)
local instance = {}
setmetatable(instance, { __index = BaseDataModel })
instance.INDEX = deepCopy(BaseDataModel.schema)
load(instance, dataFile)
return instance
end
end


Line 213: Line 251:


--region Public building interface
--region Public building interface
---@protected
---@function
---Initializes the specified record. No-op for BaseDataModel.
---
---**Likely overridden in derived classes.**
---
---@param id buildingIDString
function BaseDataModel:initializeRecord(id)
-- No-op. Override in derived classes if needed.
id = id
end


---@public
---@public
Line 224: Line 274:
local building = findName(self, name)
local building = findName(self, name)
if building then
if building then
return building[INDEX.ID]
return building[self.schema.ID]
else
else
return nil
return nil
Line 239: Line 289:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
return building[INDEX.CATEGORY]
return building[self.schema.CATEGORY1]
else
else
return nil
return nil
end
end
end
end
---@public
---@function
---Gets the secondary category for the specified ID from this instance's data.
---
---@param id buildingIDString
---@return string secondary category, or nil if not found
function BaseDataModel:getSecondCategory(id)
local building = findID(self, id)
if building then
return building[self.schema.CATEGORY2]
else
return nil
end
end


---@public
---@public
Line 254: Line 321:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
return building[INDEX.CITY_SCORE]
return building[self.schema.CITY_SCORE]
else
else
return nil
return nil
Line 272: Line 339:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
return building[INDEX.CONSTRUCTION]
return building[self.schema.CONSTRUCTION]
else
else
return nil
return nil
Line 287: Line 354:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
return building[INDEX.CONSTRUCTION_TIME]
return building[self.schema.CONSTRUCTION_TIME]
else
else
return nil
return nil
Line 302: Line 369:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
return building[INDEX.DESCRIPTION]
return building[self.schema.DESCRIPTION]
else
else
return nil
return nil
Line 334: Line 401:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
return building[INDEX.IS_MOVABLE]
return building[self.schema.IS_MOVABLE]
else
else
return nil
return nil
Line 349: Line 416:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
return building[INDEX.NAME]
return building[self.schema.NAME]
else
else
return nil
return nil
Line 364: Line 431:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
return building[INDEX.WORKPLACES]
return building[self.schema.WORKPLACES]
else
else
return nil
return nil
Line 379: Line 446:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
return building[INDEX.SIZE_X] .. " × " .. building[INDEX.SIZE_Y]
return building[self.schema.SIZE_X] .. " × " .. building[self.schema.SIZE_Y]
else
else
return nil
return nil
Line 396: Line 463:
local building = findID(self, id)
local building = findID(self, id)
if building then
if building then
if building[INDEX.STORAGE_CAP] then
if building[self.schema.STORAGE_CAP] then
return building[INDEX.STORAGE_CAP]
return building[self.schema.STORAGE_CAP]
elseif building[INDEX_STORAGE_TANK] then
elseif building[INDEX_STORAGE_TANK] then
return building[INDEX_STORAGE_TANK]
return building[INDEX_STORAGE_TANK]
end
end
else
return nil
end
end
---@public
---@function
---Checks whether the specified ID is a service-type building from this instance's data.
---
---**Likely overridden by derived classes.**
---
---@param id buildingIDString
---@return boolean _true_ if a service building, _false_ if not, or nil if not found
function BaseDataModel:isServiceBuilding(id)
-- The retrieval isn't used, but it performs necessary error checking.
local building = findID(self, id)
if building then
return false
else
else
return nil
return nil
Line 718: Line 803:


--region Public recipe data retrieval interface
--region Public recipe data retrieval interface
---@protected
---@function
function BaseDataModel.getProductID(recipe)
-- TODO next
end


-- 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.
-- 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.
Line 794: Line 885:
elseif recipeData.recipe[INDEX_RECIPE_GRADE_ALT] then
elseif recipeData.recipe[INDEX_RECIPE_GRADE_ALT] then
return LOOKUP_CONVERT_GRADE_TO_NUMBER[recipeData.recipe[INDEX_RECIPE_GRADE_ALT]]
return LOOKUP_CONVERT_GRADE_TO_NUMBER[recipeData.recipe[INDEX_RECIPE_GRADE_ALT]]
else
-- Default of zero.
return 0
end
end
end
end
Line 917: Line 1,011:




--region Public constructor
---@public
---@constructor
---Instantiates a new BaseDataModel by loading the specified data file into the instance.
---
---(Benchmarking: ~0.0170 seconds with largest data file.)
---
---@param dataFile string filename of Json data to load into this instance
---@return BaseDataModel a new instance storing the specified data
function BaseDataModel.new(dataFile)
local instance = {}
setmetatable(instance, { __index = BaseDataModel })
load(instance, dataFile)
return instance
end
--endregion


return BaseDataModel
return BaseDataModel

Revision as of 02:44, 5 November 2024

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

---@class BaseDataModel
---
---@alias buildingIDString string the unique identifier for a building
---@alias buildingRecordTable table a full record for one building
---@alias recipeTable table a record for one recipe
---@alias constructionGoodsTable table an array of construction goods' IDs and amounts
---
---Provides a standard set of getters for building data and permits overrides from derived classes to specialize the building data, to allow for exceptions, etc.
---
---@field getID function interface method
---@field getCategory function interface method
---@field getCityScore function interface method
---@field getConstructionCosts function interface method
---@field getConstructionTime function interface method
---@field getDescription function interface method
---@field getIcon function interface method
---@field isMovable function interface method
---@field getName function interface method
---@field getNumberOfWorkplaces function interface method
---@field getSize function interface method
---@field getStorage function interface method
---@field isServiceBuilding function interface method
---
---@field _ --- --- ---
---
---@field dataTable table Primary data table of _buildingRecordTables_, keyed by _buildingIDString_
---@field mapNamesToIDs table Lookup table, returns a _buildingIDString_ from a building's display name
---@field schema table data schema, with field names and subtables of field names
local BaseDataModel = {}



--region Dependencies
local JsonUtils = require("Module:JsonUtils")
--endregion



--region Protected data schema

---@protected
---Protected data schema defining Json field names for accessing data.
---@field schema table data schema, with field names and subtables of field names
BaseDataModel.schema = {
	ID = "id",
	NAME = "displayName",
	CATEGORY1 = "category",
	CITY_SCORE = "cityScore",
	CONSTRUCTION_TIME = "constructionTime",

	CONSTRUCTION_GOODS = "requiredGoods",
	CONSTRUCTION = {
		ID = "name",
		AMOUNT = "amount",
	},

	DESCRIPTION = "description",
	IS_ESSENTIAL = "initiallyEssential",
	IS_MOVABLE = "movable",
	SIZE_X = "sizeX",
	SIZE_Y = "sizeY",
	STORAGE_CAP = "storage",
	TANK = "baseTankCapacity",
	WORKPLACES = "workplaces",

	RECIPE = {
		GRADE = "grade",
		PRODUCTION_TIME = "productionTime",

		PRODUCTS = "product",
		PRODUCT = {
			ID = "name",
			AMOUNT = "amount",
		},

		INGREDIENTS = "ingredients",
		INGREDIENT = {
			OPTION = {
				ID = "name",
				AMOUNT = "amount",
			},
		},
	},
}

---Secondary category, to be defined by derived classes.
BaseDataModel.schema.CATEGORY2 = "category2"

-- This section is deprecated.
local INDEX_RECIPES = "recipes"
local INDEX_RECIPE_GRADE = "grade"
local INDEX_RECIPE_GRADE_ALT = "gradeId" -- alt for service buildings
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_STORAGE_TANK = "baseTankCapacity"

--endregion



--region Private constants

---Enum converts grade strings (keys) to numbers (values).
---@type table
local LOOKUP_CONVERT_GRADE_TO_NUMBER = {
	["Grade0"] = 0,
	["Grade1"] = 1,
	["Grade2"] = 2,
	["Grade3"] = 3,
}

--endregion



--region Localization string constants

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
--defined as @fields above in class definition
--endregion



--region Private methods

---Loads the JSON data into the two structured member variables for data access.
---
---(Benchmarking 2024-11-04:  ~0.018 seconds for biggest data file)
---
---@param instance 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 id = record[instance.schema.ID]
		local name = record[instance.schema.NAME]
		instance.dataTable[id] = record
		instance.mapNamesToIDs[name] = id

		-- If derived classes have anything to do per-record, let them here.
		instance:initializeRecord(id)
	end
	--return instance.dataTable, instance.mapNamesToIDs --uncomment when debugging
end

---Gets the record with that ID from the instance's data table.
---
---(Benchmarking 2024-11-04:  ~0 seconds for biggest data file)
---
---@param instance BaseDataModel
---@param id buildingIDString
---@return buildingRecordTable 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] or nil
end

---Gets the record with that display name from the instance's data table. If the data model has multiple buildings with the same name, this will return only one of them.
---
---(Benchmarking 2024-11-04:  ~0.0000 seconds for biggest data file)
---
---@param instance BaseDataModel
---@param name string the display name
---@return buildingRecordTable 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] or nil
end

---Deeply copies the provided table and returns the copy. Does not modify the original.
---
---@param original table to copy
---@return table the copy
local function deepCopy(original)
	local copy
	if type(original) == "table" then
		copy = {}
		for key, value in pairs(original) do
			copy[key] = deepCopy(value)
		end
	else
		copy = original
	end
	return copy
end

--endregion



--region Public constructor

---@public
---@constructor
---Instantiates a new BaseDataModel by loading the specified data file into the instance.
---
---(Benchmarking: ~0.0170 seconds with largest data file.)
---
---@param dataFile string filename of Json data to load into this instance
---@return BaseDataModel a new instance storing the specified data
function BaseDataModel.new(dataFile)

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

	instance.INDEX = deepCopy(BaseDataModel.schema)

	load(instance, dataFile)

	return instance
end

--endregion



--region Public building interface

---@protected
---@function
---Initializes the specified record. No-op for BaseDataModel.
---
---**Likely overridden in derived classes.**
---
---@param id buildingIDString
function BaseDataModel:initializeRecord(id)
	-- No-op. Override in derived classes if needed.
	id = id
end

---@public
---@function
---Gets the ID associated with the specified name in this instance's data.
---
---@param name string the display name
---@return buildingIDString or nil if not found
function BaseDataModel:getID(name)

	local building = findName(self, name)
	if building then
		return building[self.schema.ID]
	else
		return nil
	end
end

---@public
---@function
---Gets the category for the specified ID from this instance's data.
---
---@param id buildingIDString
---@return string category, or nil if not found
function BaseDataModel:getCategory(id)
	local building = findID(self, id)
	if building then
		return building[self.schema.CATEGORY1]
	else
		return nil
	end
end

---@public
---@function
---Gets the secondary category for the specified ID from this instance's data.
---
---@param id buildingIDString
---@return string secondary category, or nil if not found
function BaseDataModel:getSecondCategory(id)
	local building = findID(self, id)
	if building then
		return building[self.schema.CATEGORY2]
	else
		return nil
	end
end



---@public
---@function
---Gets the city score for the specified ID from this instance's data.
---
---@param id buildingIDString
---@return number city score, or nil if not found
function BaseDataModel:getCityScore(id)
	local building = findID(self, id)
	if building then
		return building[self.schema.CITY_SCORE]
	else
		return nil
	end
end

---@public
---@function
---Gets the construction goods for the specified ID from this instance's data.
---Returns the array of construction goods, straight from the table, in subtables of IDs and amounts.
---
---See the data schema for details.
---
---@param id buildingIDString
---@return constructionGoodsTable an array of construction goods' IDs and amounts, or nil if not found
function BaseDataModel:getConstructionCosts(id)
	local building = findID(self, id)
	if building then
		return building[self.schema.CONSTRUCTION]
	else
		return nil
	end
end

---@public
---@function
---Gets the time required for construction for the specified ID from this instance's data.
---
---@param id buildingIDString
---@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[self.schema.CONSTRUCTION_TIME]
	else
		return nil
	end
end

---@public
---@function
---Gets the description for the specified ID from this instance's data.
---
---@param id buildingIDString
---@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[self.schema.DESCRIPTION]
	else
		return nil
	end
end

---@public
---@function
---Gets the icon filename for the specified ID from this instance's data.
---
---**Likely overridden by derived classes.**
---
---@param id buildingIDString
---@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

---@public
---@function
---Checks whether the specified ID is movable from this instance's data.
---
---@param id buildingIDString
---@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[self.schema.IS_MOVABLE]
	else
		return nil
	end
end

---@public
---@function
---Gets the name for the specified ID from this instance's data.
---
---@param id buildingIDString
---@return string the name, or nil if not found
function BaseDataModel:getName(id)
	local building = findID(self, id)
	if building then
		return building[self.schema.NAME]
	else
		return nil
	end
end

---@public
---@function
---Gets the number of workplaces for the specified ID from this instance's data.
---
---@param id buildingIDString
---@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[self.schema.WORKPLACES]
	else
		return nil
	end
end

---@public
---@function
---Gets the size for ths specified ID from this instance's data, expressed as an X-by-Y string.
---
---@param id buildingIDString
---@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[self.schema.SIZE_X] .. " × " .. building[self.schema.SIZE_Y]
	else
		return nil
	end
end

---@public
---@function
---Gets the storage capacity for the specified ID from this instance's data.
---
---**Likely overridden by derived classes.**
---
---@param id buildingIDString
---@return number the storage capacity, or nil if not found
function BaseDataModel:getStorage(id)
	local building = findID(self, id)
	if building then
		if building[self.schema.STORAGE_CAP] then
			return building[self.schema.STORAGE_CAP]
		elseif building[INDEX_STORAGE_TANK] then
			return building[INDEX_STORAGE_TANK]
		end
	else
		return nil
	end
end

---@public
---@function
---Checks whether the specified ID is a service-type building from this instance's data.
---
---**Likely overridden by derived classes.**
---
---@param id buildingIDString
---@return boolean _true_ if a service building, _false_ if not, or nil if not found
function BaseDataModel:isServiceBuilding(id)
	-- The retrieval isn't used, but it performs necessary error checking.
	local building = findID(self, id)
	if building then
		return false
	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 buildingIDString
---@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

---getIDsAndRecipesWhereProductIDAndIngredientID
---@param productID string the product ID
---@param ingredientID string the ingredient ID
---@return table array of pairs of buildingIDs and recipes, or {} if none found
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 with largest data file)
---
---@param productID string the product
---@param buildingID buildingIDString
---@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 with largest data file)
---
---@param ingredientID string
---@param buildingID buildingIDString
---@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

---@protected
---@function
function BaseDataModel.getProductID(recipe)
-- TODO next
end

-- 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 LOOKUP_CONVERT_GRADE_TO_NUMBER[recipeData.recipe[INDEX_RECIPE_GRADE]]

	elseif recipeData.recipe[INDEX_RECIPE_GRADE_ALT] then
		return LOOKUP_CONVERT_GRADE_TO_NUMBER[recipeData.recipe[INDEX_RECIPE_GRADE_ALT]]
	else
		-- Default of zero.
		return 0
	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

	return 0
end

--endregion



return BaseDataModel