Module:Item recipe: Difference between revisions

From Space Station 14 Wiki
(more friendlier error message on product's item ID lookup fail)
(better error message on unknown material)
Line 303: Line 303:


if not itemModule.item_exists_by_id_or_name(material) then
if not itemModule.item_exists_by_id_or_name(material) then
error("failed to generate a recipe for item: material '" .. material .. "' was not found in item registry. make sure that the material is added to item name overrides")
error("failed to generate a recipe for item ID '" .. item_id .."' produced on '" .. method .. "': material '" .. material .."' was not found in item registry. Make sure that the material is added to item name overrides")
end
end



Revision as of 10:28, 6 September 2024

Module documentation
View or edit this documentation (about module documentation)

Implements {{item recipe}}.

JSON files

JSON files that are updated automatically, syncing with the upstream:

Warning
Do not make changes to the above JSON files - any changes made will be erased on next update.

JSON files that are filled manually:

  • Module:Item recipe/order of materials.json - a 1 to 1 mapping of recipe materials to order at which they appear in recipes. Less number = higher order. Materials that do not have an order defined here, will appear after those that do.
  • Module:Item recipe/product overrides.json - a 1 to 1 mapping of recipe products to item IDs. Not all products are the same as item IDs they "represent", so sometimes a connection needs to be established explicitly.

-- Contains utilities for working with in-game item recipes.

-- todo: material sorting. based on alphabetical sorting? maybe at .json generation step, convert materials to an array?

local p = {} --p stands for package
local getArgs = require('Module:Arguments').getArgs
local itemModule = require('Module:Item')
local yesNo = require('Module:Yesno')

-- A table mapping production methods to recipes that a given method can produce.
local recipes_by_method = mw.loadJsonData("Module:Item recipe/recipes/lathes.json")

local product_overrides = mw.loadJsonData("Module:Item recipe/product overrides.json")

local order_of_materials = mw.loadJsonData("Module:Item recipe/order of materials.json")

local current_frame = mw:getCurrentFrame()

local methods_items_els_cache = {}

local was_template_styles_tag_el_added = false

-- ====================


local function numeric_table_length(t)
	local count = 0
	for _ in ipairs(t) do count = count + 1 end
	return count
end

local function table_length(t)
	local count = 0
	for _ in pairs(t) do count = count + 1 end
	return count
end

local function table_has_value(tab, val)
	for _, value in ipairs(tab) do
		if value == val then
			return true
		end
	end

	return false
end

local function assert_value_not_nil(value, error_message)
	if value == nil then
		if error_message == nil then
			error("value is nil")
		else
			error(error_message)
		end
	end
end

-- Given a value, checks if it's "nil".
-- * If it's not - returns the `value`.
-- * IF it is - returns the `value_if_nil`.
local function nil_or(value, value_if_nil)
	if value == nil then
		return value_if_nil
	else
		return value
	end
end

local function find_first_numeric_table_item_matching_condition(table, condition)
	for i, item in ipairs(table) do
		if condition(item, i, table) then
			return item
		end
	end
end

local function find_first_table_item_matching_condition(table, condition)
	for key, value in pairs(table) do
		if condition(key, value, table) then
			return value
		end
	end
end

local function find_first_table_item_key_matching_condition(table, condition)
	for key, value in pairs(table) do
		if condition(key, value, table) then
			return key
		end
	end
end

-- ====================

local function assert_product_exists(product)
	if not itemModule.item_exists_by_id_or_name(product) then
		error("no recipe product with name/ID '".. product .."' was found. Make sure that the product exists with that name/ID or an override is defined in the item recipe module product overrides")
	end
end

-- Searchs for a recipe product based on item ID that a recipe should produce.
-- Not all recipes produce products that you might think they do -
-- some produce their own "printed" or "empty" or other variants.
--
-- Note that these mapping must be defined manually in the `product_overrides`.
--
-- For instance, a recipe for the small power cell produces `PowerCellSmallPrinted` item,
-- whereas the actual power cell item has ID `PowerCellSmall`.
--
-- So, to find the recipe for the small power cell, we first would need to define a mapping from
-- the recipe product `PowerCellSmallPrinted` to the actual item `PowerCellSmall`.
-- After that, this this function can be used to get the actual items based on the recipe product.
local function lookup_recipe_product_by_item_id(product_item_id)
	local match = find_first_table_item_key_matching_condition(
		product_overrides,
		function(key, value) return value == product_item_id end
	)

	if match then
		return match
	else
		return product_item_id
	end
end

-- todo desc
local function lookup_item_id_by_recipe_product(product_item_id)
	local match = product_overrides[product_item_id]

	if match then
		return match
	else
		return product_item_id
	end
end

-- Searches a recipe of a given item.
-- If `production_method` is specified, only looks through the recipes with that method,
-- otherwise searching across all methods until a match is found.
--
-- Returns table with `production_method` and `recipe`, or `nil` if no recipe was found.
local function lookup_recipe_by_item_id(item_id, production_method)
	assert_value_not_nil(item_id, "failed to lookup recipes by method and item ID: item ID was not provided")

	-- generate a list of production methods to search across
	local methods_to_lookup = {}
	if production_method == nil then
		-- no method specified = search across all methods
		for recipe_group_method, _ in pairs(recipes_by_method) do
			table.insert(methods_to_lookup, recipe_group_method)
		end
	else
		-- method specified = only look through recipes with that production method
		-- lookup the proper name for the method, which is gonne be just another item ID
		table.insert(methods_to_lookup, itemModule.try_lookup_item_id(production_method))
	end

	-- apply a product override if needed
	item_id = lookup_recipe_product_by_item_id(item_id)

	-- do the search
	for _, production_method in ipairs(methods_to_lookup) do
		local match = recipes_by_method[production_method][item_id]
		if match then
			return {
				production_method = production_method,
				recipe = match
			}
		end
	end
end

-- Searches a material item in the material order config, returning the order number or `nil`,
-- if the config doesn't have the queried item.
local function lookup_order_of_material(item_id)
	assert_value_not_nil(item_id, "failed to lookup order of material: item ID was not provided")

	return order_of_materials[item_id]
end

-- Produces a "note" element used in generation of item recipes.
-- Takes in a CSS-compatible color and text content.
local function generate_note_element(color, text)
	local el = mw.html.create('span')
		:addClass("item-recipe-note")
		:node(text)

	if color then
		el:css('color', color)
	end

	return el
end

local function generate_info_icon(frame, kind)
	if kind == 'research' then
		return frame:expandTemplate {
			title = 'Tooltip',
			args = {
				"[[File:JobIconResearchDirector.png|24px|link=Research_and_Development#R&D_Tree]]",
				"This recipe is unlocked by '''research'''"
			}
		}
	elseif kind == 'emag' then
		return frame:expandTemplate {
			title = 'Tooltip',
			args = {
				"[[File:Emag.png|42px|link=Cryptographic Sequencer]]",
				"This recipe is unlocked by '''EMAG'''"
			}
		}
	elseif kind == 'progression-symbol' then
		return mw.html.create("span")
			:addClass('info-icon-progression-symbol')
			:node("↓")
	else
		error("failed to generate an info icon: unknown kind " .. kind)
	end
end

-- ====================

-- Generates a recipe element for a given item.
-- This is the main external function of this module.
function p.generate_item_recipe(frame)
	local args = getArgs(frame)

	-- [REQUIRED]

	-- Item name, alias or ID. Required.
	local item = args[1]
	assert_value_not_nil(item, "failed to generate a recipe for item: item was not provided")

	-- [OPTIONAL]

	-- Amount of item. Default is 1.
	-- Must be a string since Module:Item uses string amount.
	-- All values from templates come as strings.
	local amount = nil_or(args[2], "1")

	-- Item production method. Can be "nil", in which case it's looked up.
	local method = args[3]


	-- Whether to only generate a materials block.
	local materials_only = yesNo(args["materials only"] or args["mat only"] or false)

	-- Layout of materials in materials only mode.
	local materials_only_layout = args["materials only layout"] or args["mat only layout"] or "vertical"

	-- ============

	assert_product_exists(item)
	local item_id = itemModule.try_lookup_item_id(item)

	local recipe_lookup_result = lookup_recipe_by_item_id(item_id, method)
	assert_value_not_nil(recipe_lookup_result,
		"failed to generate a recipe for item: no recipe found for item " ..
		item_id .. " (method: " .. (method or "nil") .. "; original item name: ".. item ..")")

	local recipe = recipe_lookup_result.recipe
	local method = recipe_lookup_result.production_method


	local recipe_el = mw.html.create("div")
		:addClass("item-recipe")

	if materials_only_layout == "vertical" or materials_only_layout == "ver" then
		recipe_el:addClass("item-recipe-materials-layout-vertical")
	else
		recipe_el:addClass("item-recipe-materials-layout-horizontal")
	end


	local body_el = mw.html.create("div")
		:addClass('item-recipe-body')
		:css('background', 'linear-gradient(135deg, var(--bg-color-light-x2), var(--bg-color-light))')


	local materials_el = mw.html.create("div")
		:addClass("item-recipe-materials")

	assert_value_not_nil(recipe.materials,
		"failed to generate a recipe for item: no 'materials' are specified for item " ..
		item_id .. " recipe (method: " .. method .. ")")

	-- get a list of materials (item ids)
	local materials_item_ids = {}
	for item_id in pairs(recipe.materials) do
		table.insert(materials_item_ids, item_id)
	end

	-- sort materials list based on material order config
	-- if item doesn't have an order, use a big number as its order, placing them after
	-- materials that have an order defined
	table.sort(materials_item_ids, function(first, second)
		return (lookup_order_of_material(first) or 999999999999)
			< (lookup_order_of_material(second) or 999999999999)
	end)

	for _, material in ipairs(materials_item_ids) do
		local cost = recipe.materials[material]

		if not itemModule.item_exists_by_id_or_name(material) then
			error("failed to generate a recipe for item ID '" .. item_id .."' produced on '" .. method .. "': material '" .. material .."' was not found in item registry. Make sure that the material is added to item name overrides")
		end


		materials_el:node(itemModule.generate_item { material, cost })
	end

	body_el:node(materials_el)


	if materials_only then
		recipe_el:addClass("materials-only")

		recipe_el:node(body_el)
	else
		local header_el = mw.html.create("div")
			:addClass("item-recipe-header")
			:css('background', 'linear-gradient(120deg, var(--bg-color-light-x3), var(--bg-color-light-x2))')

		recipe_el:node(header_el)


		local product_and_method_container_el = mw.html.create("div")
			:addClass("item-recipe-product-and-method-container")

		header_el:node(product_and_method_container_el)


		local product_el = itemModule.generate_item { [1] = item_id, [2] = amount, cap = true }
			:addClass("item-recipe-product")

		product_and_method_container_el:node(product_el)


		-- TODO: not all methods will be items, so this will eventually break.
		local method_el = methods_items_els_cache[method]
		if method_el == nil then
			method_el = mw.html.create("span")
				:addClass('item-recipe-method')
				:node(itemModule.generate_item { [1] = method, capitalize = true })

			methods_items_els_cache[method] = method_el
		end

		product_and_method_container_el:node(method_el)


		local info_icons_el = mw.html.create("div")
			:addClass("item-recipe-info-icons")

		header_el:node(info_icons_el)


		recipe_el:node(body_el)


		local complete_time_el = mw.html.create("span")
			:addClass('item-recipe-complete-time')
			:node((recipe.completetime * amount) .. " " .. "sec.")

		body_el:node(complete_time_el)


		local notes_el = mw.html.create("div")
			:addClass("item-recipe-notes")
			:css('background', 'linear-gradient(40deg, var(--bg-color-light-x3), var(--bg-color-light-x2))')


		assert_value_not_nil(recipe.availability, "failed to generate a recipe for item '" .. item_id .. "': recipe doesn't have its 'availability defined")

		-- if recipe is not available by default,
		-- generate "info icons" and notes (if needed) telling about it.
		if recipe.availability ~= 'static' then
			local is_recipe_unlocked_by_research = string.find(recipe.availability, "dynamic", 1, true) ~= nil
			local is_recipe_unlocked_by_emag = string.find(recipe.availability, "emag", 1, true) ~= nil
			local is_recipe_unlocked_by_research_and_then_emag = is_recipe_unlocked_by_research and
				is_recipe_unlocked_by_emag

			if is_recipe_unlocked_by_research_and_then_emag then
				recipe_el:addClass('item-recipe-by-research')
				recipe_el:addClass('item-recipe-by-emag')

				info_icons_el:node(generate_info_icon(current_frame, 'research'))
				info_icons_el:node(generate_info_icon(current_frame, 'progression-symbol'))
				info_icons_el:node(generate_info_icon(current_frame, 'emag'))

				notes_el:node(
					generate_note_element(
						nil,
						current_frame:preprocess(
							"'''This recipe is unlocked by [[Cryptographic Sequencer|EMAG]] after it has been [[Research_and_Development#R&D_Tree|researched]]'''")
					)
				)
			elseif is_recipe_unlocked_by_research then
				recipe_el:addClass('item-recipe-by-research')

				info_icons_el:node(generate_info_icon(current_frame, 'research'))

				-- if not is_recipe_unlocked_by_research_and_then_emag then
				-- 	notes_el:node(
				-- 		generate_note_element(
				-- 			"gold",
				-- 			"'''This recipe is unlocked by research'''"
				-- 		)
				-- 	)
				-- end
			else
				recipe_el:addClass('item-recipe-by-emag')

				info_icons_el:node(generate_info_icon(current_frame, 'emag'))

				-- if not is_recipe_unlocked_by_research_and_then_emag then
				-- 	notes_el:node(
				-- 		generate_note_element(
				-- 			"var(--danger-color)",
				-- 			current_frame:preprocess(
				-- 			"'''This recipe is unlocked by [[Cryptographic Sequencer|{{item|EmagUnlimited|l=EMAG}}]]'''")
				-- 		)
				-- 	)
				-- end
			end
		end

		recipe_el:node(notes_el)
	end


	if not was_template_styles_tag_el_added then
		recipe_el:node(current_frame:extensionTag("templatestyles", "", { src = 'Template:Item recipe/styles.css' }))

		was_template_styles_tag_el_added = true
	end

	return recipe_el
		:allDone()
end

-- Generates an alphabetical list of recipes (elements) for a given production method.
-- Used to list all recipes for a particular method.
-- Recipes are sorted based on their products (the items display names).
function p.generate_list_of_recipes_for_method(frame)
	local args = getArgs(frame)

	local method = args[1]
	assert_value_not_nil(method, "failed to generate a list of recipes for a method: method was not provided")

	-- Limit on how many recipes to generate
	local recipes_limit = tonumber(args.limit) or 99999999

	local recipes = recipes_by_method[itemModule.try_lookup_item_id(method)]
	assert_value_not_nil(recipes, "failed to generate a list of recipes for a method: unknown method: " .. method)

	-- =======================

	local container_el = mw.html.create("div")
		:addClass("item-recipes-list")

	-- generate a list of products
	local products = {}
	local i = 1
	for product_item_id, _ in pairs(recipes) do
		table.insert(products, product_item_id)

		if i == recipes_limit then
			break
		end

		i = i + 1
	end

	-- sort the list of products alphabetically
	-- by looking up the item names and comparing them
	table.sort(products, function(first, second)
		local first_item_id = lookup_item_id_by_recipe_product(first)
		local second_item_id = lookup_item_id_by_recipe_product(second)

		assert_product_exists(first_item_id)
		assert_product_exists(second_item_id)

		return itemModule.lookup_item_name_by_id(first_item_id)
			< itemModule.lookup_item_name_by_id(second_item_id)
	end)

	-- generate recipe elements
	for _, product in ipairs(products) do
		container_el:node(p.generate_item_recipe {
			[1] = lookup_item_id_by_recipe_product(product),
			[2] = 1,
			[3] = method
		})
	end

	return container_el
		:allDone()
end

return p