Module:Item recipe: Difference between revisions

From Space Station 14 Wiki
(wip)
(removed custom reagents handlers in favor of default logic; fixed a bug where lookup_recipe_ids_by_product_id() wasn't always returning an array; itty-bitty refactor)
 
(77 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Contains utilities for working with in-game item recipes.
-- 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 p = {} --p stands for package
Line 8: Line 6:
local yesNo = require('Module:Yesno')
local yesNo = require('Module:Yesno')


-- An array of recipe groups.
-- A table mapping recipe IDs to recipes.
-- Recipes are grouped by `method`.
local recipes_by_recipe_ids = mw.loadJsonData("Module:Item recipe/recipes by recipe IDs.json")
-- Note that the order matters for perfomance - the recipe lookup happens from top to bottom.
 
local recipe_groups = {
-- A table mapping product IDs to recipe IDs.
{ method = "autolathe", recipes = mw.loadJsonData("Module:Item recipe/recipes by lathe/autolathe.json") },
-- A product ID can have either a single recipe ID mapped to it, or multiple if there's are multiple recipes.
{ method = "protolathe", recipes = mw.loadJsonData("Module:Item recipe/recipes by lathe/protolathe.json") },
local recipe_ids_by_product_ids = mw.loadJsonData("Module:Item recipe/recipe IDs by product IDs.json")
}
 
-- A table mapping production methods to recipe IDs, with a intermediate mapping by availability.
local recipe_ids_by_method_and_availability = mw.loadJsonData(
    "Module:Item recipe/recipe IDs by method and availability.json")
 
-- A table mapping material IDs (item IDs) to their display order.
-- Order is just a number. Materials with lesser order number will appear first.
local materials_order_by_material_ids = mw.loadJsonData("Module:Item recipe/order of materials.json")
 
-- A table remapping product IDs.
--
-- Not all recipes produce products that you might think they do -
-- some produce their own "printed" or "empty" or other variants.
--
-- For instance, a recipe for the small power cell produces `PowerCellSmallPrinted` item,
-- whereas the actual power cell item has ID `PowerCellSmall`.
--
-- So, for the module functions to be able 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 is done, a lookup for small power cell (or its id) will return the corresponding recipe.
local product_overrides = mw.loadJsonData("Module:Item recipe/product overrides.json")


-- A table mapping overriding some recipes' products.
local current_frame = mw:getCurrentFrame()
-- Keys are the recipe products, values are the new products for these recipes.
-- It's advised to use item IDs for values, as they offer much more perfomance.
--
-- Used for recipes which use separate items for produced items,
-- which are the same or almost the same as regular variants.
-- This lets the system know the proper items to display.
local recipes_products_overrides = mw.loadJsonData("Module:Item recipe/product overrides.json")


-- A table containing item recipes, identified by recipe IDs.
local methods_items_els_cache = {}
-- local recipes_by_recipe_id =


-- A table containing item recipe categories, identified by recipe category IDs.
local was_template_styles_tag_el_added = false
-- local recipy_categories_by_recipe_category_id = mw.loadJsonData("Module:Item recipe/recipy categories by recipe category id.json")


-- ====================
-- ====================
Line 35: Line 46:


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


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


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


return false
    return false
end
 
local function filter_table(tab, predicate)
    local tab_filtered = {}
    for key, value in pairs(tab) do
        if predicate(key, value) then
            tab_filtered[key] = value
        end
    end
 
    return tab_filtered
end
end


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


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


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


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


local function find_first_table_item_key_matching_condition(table, condition)
local function find_first_table_item_key_matching_condition(table, condition)
for key, item in pairs(table) do
    for key, value in pairs(table) do
if condition(item, key, table) then
        if condition(key, value, table) then
return key
            return key
end
        end
end
    end
end
 
local function map_numeric_table(tbl, f)
    local t = {}
    for i, v in ipairs(tbl) do
        t[i] = f(v, i)
    end
    return t
end
local function map_table(tbl, f)
    local t = {}
    for k, v in pairs(tbl) do
        t[k] = f(k, v)
    end
    return t
end
 
local function passthrough_assert_true(value, valueToReturnIfTrue, errorMessageOnFalse)
    if value then
        return valueToReturnIfTrue
    else
        error(errorMessageOnFalse)
    end
end
 
local function ternary_strict(valueToCheck, valueIfTrue, valueIfFalse)
    if valueToCheck == true then
        return valueIfTrue
    else
        return valueIfFalse
    end
end
 
-- formats seconds to a string `X min. Y sec.`.
--
-- - `X min.` part is omitted if there's less than a minute.
-- - `Y sec.` part is omitter if there's no seconds left.
local function format_seconds_to_short_string(input_seconds)
    local minutes = math.floor(input_seconds / 60)
    local seconds = input_seconds - minutes * 60
 
    local minutes_part = ternary_strict(minutes > 0, minutes .. " min.", nil)
    local seconds_part = ternary_strict(seconds > 0, seconds .. " sec.", nil)
 
    if minutes_part ~= nil and seconds_part ~= nil then
        return minutes_part .. " " .. seconds_part
    elseif seconds_part ~= nil then
        return seconds_part
    elseif minutes_part ~= nil then
        return minutes_part
    else
        return ''
    end
end
end


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


local function get_recipes_by_method(method)
-- ######################
local recipe_group = find_first_numeric_table_item_matching_condition(
-- ### METHOD LOOKUPS ###
recipe_groups,  
-- ######################
function (item)  
 
return item.method == method
-- Lookups recipe IDs by production method `production_method`.
end
--
)
-- Returns recipe IDs grouped by availability.
--
-- Raises an error if no recipe IDs were found by the production method.
-- Set `no_error` to `true` to return `nil` instead.
function p.lookup_recipe_ids_and_availability_by_method(production_method, no_error)
    assert_value_not_nil(production_method)
 
    local method_item_id = p.lookup_method_item_id(production_method)
 
    return recipe_ids_by_method_and_availability[method_item_id]
        or passthrough_assert_true(
            no_error,
            nil,
            "recipe IDs by production method lookup failed: no recipes by production method '" ..
            method_item_id .. "' (input method: '" .. production_method .. "')"
        )
end
 
-- Lookups production methods by recipe ID `recipe_id`.
--
-- Returns an array of tables, each containg:
-- - `method` - production method.
-- - `availability` - availability for the recipe for this production method.
--
-- **NOTE:** This is an expensive function.
function p.lookup_methods_with_availability_by_recipe_id(inpit_recipe_id)
    local result = {}


if recipe_group == nil then
    for method, recipe_ids_grouped in pairs(recipe_ids_by_method_and_availability) do
error("failed to get recipes by method: no such recipe group with method " .. method)
        for availability, recipe_ids in pairs(recipe_ids_grouped) do
end
            if numeric_table_has_value(recipe_ids, inpit_recipe_id) then
                table.insert(result, {
                    method = method,
                    availability = availability
                })
            end
        end
    end


return recipe_group.recipes
    return result
end
end


-- Given an item ID as a some recipe product, lookups it in the prduct overrides.
-- Checks whether a production method `production_method` exists.
-- On a match, returns a new item ID to use as a product.
-- Any casing is allowed.
local function resolve_recipe_product_override_if_needed(product_item_id)
function p.method_exists(production_method)
local match_override_product = find_first_table_item_matching_condition(
    assert_value_not_nil(production_method)
recipes_products_overrides,
function (override_product, recipe_product) return recipe_product == product_item_id end
)


if match_override_product == nil then
    return p.lookup_recipe_ids_and_availability_by_method(production_method, true) ~= nil
return product_item_id
else
return match_override_product
end
end
end


-- Searches a recipe for a given item, optionally with a specific production method.
-- Asserts that a production method `production_method` exists.
-- @returns A table with `method` and `recipe`, or `nil` if no recipe was found.
-- Any casing is allowed.
local function lookup_recipe_by_item_id_and_method(item_id, method)
function p.assert_method_exists(production_method, custom_message)
assert_value_not_nil(item_id, "failed to lookup recipes by method and item ID: item ID was not provided")
    assert_value_not_nil(production_method)
 
    if not p.method_exists(production_method) then
        if custom_message then
            error("production method exists assertion failed for method '" ..
                production_method .. "': " .. custom_message)
        else
            error("production method exists assertion failed for method '" ..
                production_method .. "': production method is not defined")
        end
    end
end


-- production methods to lookup through
-- Lookups method item ID.
local methods_to_lookup = {}
--
if method == nil then
-- Raises an error if no item ID was found.
-- no method specified = look through all methods until the recipe is found
-- Set `no_error` to `true` to return `nil` instead.
for _, recipe_group in ipairs(recipe_groups) do
--
table.insert(methods_to_lookup, recipe_group.method)
-- todo this will break in the future
end
function p.lookup_method_item_id(method, no_error)
else
    assert_value_not_nil(method)
-- method specified = only look through recipes with that production method
table.insert(methods_to_lookup, method)
end


-- do the search
    return itemModule.lookup_item_id(method, true)
for _, method in ipairs(methods_to_lookup) do
        or passthrough_assert_true(
for _, recipe in ipairs(get_recipes_by_method(method)) do
            no_error,
if resolve_recipe_product_override_if_needed(recipe.result) == item_id then
            nil,
return {
            "production method item ID lookup failed: no item ID was found for method '" ..
method = method,
            method .. "'"
recipe = recipe
        )
}
end
end
 
end
-- ################################
end
-- ### PRODUCT OVERRIDE LOOKUPS ###
-- ################################
 
-- Lookups an override for product ID `product_id`.
--
-- For instance, if there was an override `PowerCellSmallPrinted` that maps to to item ID `PowerCellSmall`,
-- this functions would return `PowerCellSmall` if `product_id` was `PowerCellSmallPrinted`.
--
-- Raises an error if no match was found. Set `no_error` to `true` to return `nil` instead.
function p.lookup_product_id_override(product_id, no_error)
    assert_value_not_nil(product_id)
 
    return product_overrides[product_id]
        or passthrough_assert_true(
            no_error,
            nil,
            "product override lookup failed: no override was found with product ID '" ..
            product_id .. "'"
        )
end
 
-- Lookups the prodct ID that was overriden with item ID `item_id`.
--
-- For instance, if there was an override `PowerCellSmallPrinted` that maps to to item ID `PowerCellSmall`,
-- this functions would return `PowerCellSmallPrinted` if `item_id` was `PowerCellSmall`.
--
-- **NOTE:** This is an expensive function.
--
-- Raises an error if no match was found. Set `no_error` to `true` to return `nil` instead.
function p.reverse_lookup_product_id_override(item_id, no_error)
    assert_value_not_nil(item_id)
 
    return find_first_table_item_key_matching_condition(
            product_overrides,
            function(_, value) return value == item_id end
        )
        or passthrough_assert_true(
            no_error,
            nil,
            "reverse product override lookup failed: no override was found that maps to item ID '" ..
            item_id .. "'"
        )
end
 
-- #######################
-- ### PRODUCT LOOKUPS ###
-- #######################
 
-- Checks whether a an item `product` exists.
-- Takes into account that `product` can have a product override.
function p.product_exsits(product)
    assert_value_not_nil(product)
 
    local product_with_override = p.lookup_product_id_override(product, true)
        or product
 
    return itemModule.item_exists(product_with_override)
end
 
-- Asserts that a product `product` exists.
-- `product` can be a product ID (including overriden ones), item ID or name.
function p.assert_product_exists(product, custom_message)
    assert_value_not_nil(product)
 
    if not p.product_exsits(product) then
        if custom_message then
            error("product exist assertion failed for product '" .. product .. "': " .. custom_message)
        else
            error("product exist assertion failed for product '" ..
                product ..
                "': no product was found. Make sure that a recipe exists with given product or that a product override is defined in Module:Item recipe")
        end
    end
end
 
-- Lookups recipe IDs by product ID `product_id`.
-- Accepts overriden `product_id`s.
--
-- Returns an array of matches.
--
-- **NOTE:** This is an expensive function.
function p.lookup_recipe_ids_by_product_id(product_id)
    assert_value_not_nil(product_id)
 
    local result = recipe_ids_by_product_ids[
    p.reverse_lookup_product_id_override(product_id, true)
    or product_id
    ]
 
    -- always returns an array
    if type(result) == 'string' then
        return { result }
    elseif result ~= nil then
        return result
    else
        return {}
    end
end
 
-- ######################
-- ### RECIPE LOOKUPS ###
-- ######################
 
-- Lookups recipe by recipe ID `recipe_id`.
--
-- Returns `nil` if no recipe was found.
-- Set `no_error` to `true` to return `nil` instead.
function p.lookup_recipe_by_recipe_id(recipe_id, no_error)
    assert_value_not_nil(recipe_id)
 
    return recipes_by_recipe_ids[recipe_id]
        or passthrough_assert_true(
            no_error,
            nil,
            "failed to lookup recipe by recipe id '" .. recipe_id .. "': no recipe was found"
        )
end
 
-- Asserts that a recipe ID `recipe_id` exists.
-- function assert_recipe_id_exists(recipe_id)
--    error("not impl")
-- end
 
-- Searches recipes by `query`. `query` can be an product ID (item ID - including overrden ones),
-- name or a recipe ID.
function p.search_recipes(query)
    assert_value_not_nil(query, "failed to lookup recipes by method and item ID: item ID was not provided")
 
    -- check if query is a recipe ID
    local recipe_by_recipe_id = recipes_by_recipe_ids[query]
    if recipe_by_recipe_id then
        -- if so - we got a direct match!
        return { recipe_by_recipe_id }
    end
 
    -- check if query is an item name/ID
    -- this is a last possibility.
    -- TODO add a custom errror here?
    local recipe_product_by_item_id_or_name = itemModule.lookup_item_id(query)
 
    local recipe_ids_by_item_id_or_name = p.lookup_recipe_ids_by_product_id(recipe_product_by_item_id_or_name)
 
    local recipes = {}
    if recipe_ids_by_item_id_or_name ~= nil then
        for _, recipe_id in ipairs(recipe_ids_by_item_id_or_name) do
            table.insert(recipes, p.lookup_recipe_by_recipe_id(recipe_id))
        end
    end
 
    return recipes
end
 
-- Lookups recipes by production method.
--
-- Raises an error if no recipes were found by the production method.
function p.lookup_recipes_by_production_method(method)
    assert_value_not_nil(method, "failed to lookup recipes by production method: no method was given")
 
    p.assert_method_exists(method,
        "failed to lookup recipes by production method: method '" .. method .. "' doesn't exist")
 
    local recipe_ids_grouped = p.lookup_recipe_ids_and_availability_by_method(method)
 
    local recipes = {}
    for availability, recipe_ids in pairs(recipe_ids_grouped) do
        for _, recipe_id in ipairs(recipe_ids) do
            local recipe = p.lookup_recipe_by_recipe_id(recipe_id)
            table.insert(recipes, recipe)
        end
    end
 
    return recipes
end
 
-- Filters given recipes by production method.
-- Any casing is allowed for production method.
-- function p.filter_recipes_by_production_method(recipes, production_method)
--    p.assert_method_exists(production_method)
 
--    return filter_table(
--        p.lookup_recipe_ids_by_method(production_method),
--        function(_, recipe_ids_grouped)
--            return find_first_numeric_table_item_matching_condition(
--                recipes,
--                function(recipe) return recipe.id == recipe_ids_grouped end
--            ) ~= nil
--        end
--    )
-- end
 
-- ##############################
-- ### MATERIAL ORDER LOOKUPS ###
-- ##############################
 
-- Searches a material item using the material order config, returning the order number or `nil`,
-- if the material order config doesn't have the queried item.
--
-- Takes in an item ID or name.
local function try_lookup_order_of_material(material)
    assert_value_not_nil(material, "failed to lookup order of material: material was not provided")
 
    local item_id = itemModule.lookup_item_id(material, true)
 
    if item_id == nil then
        error("failed to lookup order of material: material '" .. material .. "' does not exist")
    end
 
    return materials_order_by_material_ids[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
end


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


-- Generates a recipe element for a given item.
-- This is the main external function of this module.
function p.generate_item_recipe(frame)
function p.generate_item_recipe(frame)
local args = getArgs(frame)
    local args = getArgs(frame)
 
    -- [REQUIRED]
 
    -- Item name, alias, item ID or a recipe ID. Required.
    local input_query = args[1]
    assert_value_not_nil(input_query, "failed to generate a recipe for query: query 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 input_amount = nil_or(args[2], "1")
 
    -- Item production method. Can be "nil", in which case it's looked up.
    local input_method = args[3]
 
 
    -- Whether to only generate a materials block.
    local input_materials_only = yesNo(args["materials only"] or args["mat only"] or false)
 
    -- Layout of materials in materials only mode.
    local input_materials_only_layout = args["materials only layout"] or args["mat only layout"] or "vertical"
 
    -- ============
 
    -- search recipes
 
    local recipes = p.search_recipes(input_query)
    local recipes_count = numeric_table_length(recipes)
    if recipes_count == 0 then
        error("failed to generate a recipe for item: no recipe was found for item '" ..
            input_query ..
            "' (input method: '" ..
            (input_method or "nil") ..
            "'). Make sure a recipe exists for this item or define a product override for an existing recipe in Module:Item recipe")
    elseif recipes_count > 1 then
        error("failed to generate a recipe for item: found multiple recipes for item '" ..
            input_query ..
            "' (input method: '" ..
            (input_method or "nil") ..
            "'). Rendering multiple recipes is currently unsupported")
    end
 
    local recipe = recipes[1]
 
    -- search recipe methods
 
    local recipe_methods_lookup = p.lookup_methods_with_availability_by_recipe_id(recipe.id)
    local recipe_methods_count = table_length(recipe_methods_lookup)
    if recipe_methods_count == 0 then
        error("failed to generate a recipe for item: no methods were found for recipe ID '" ..
            recipe.id ..
            "' (input query: '" .. input_query .. "'; input method: '" ..
            (input_method or "nil") ..
            "'). This shouldn't usually happen because the present recipes are bound to some production methods. Probable cause: bug in the recipe generation code")
    elseif recipe_methods_count > 1 and input_method == nil then
        local methods = map_numeric_table(
            recipe_methods_lookup,
            function(match)
                return match.method
            end
        )
 
        error("failed to generate a recipe for item: found multiple production methods for recipe ID '" ..
            recipe.id ..
            "' (input query: '" ..
            input_query ..
            "') and input production method was NOT specified. Rendering multiple recipes is unsupported, so please specify a production method from available methods for this recipe: '" ..
            table.concat(methods, "', '") .. "'")
    end
 
    local recipe_method
    local recipe_availability
    if input_method == nil then
        -- if no input methods is specified, use the single available recipe method
        recipe_method = recipe_methods_lookup[1].method
        recipe_availability = recipe_methods_lookup[1].availability
    else
        -- otherwise, the number of available methods can vary,
        -- so filter it down to a single one based on the input method.
 
        local method_item_id = p.lookup_method_item_id(input_method)
 
        local recipe_methods_lookup_match = find_first_numeric_table_item_matching_condition(
            recipe_methods_lookup,
            function(lookup_result)
                return lookup_result.method == method_item_id
            end
        )
 
        if recipe_methods_lookup_match == nil then
            error("failed to generate a recipe for item: no production methods were found for recipe ID '" ..
                recipe.id ..
                "' (input query: '" ..
                input_query ..
                "') matching input method '" ..
                (input_method or "nil") ..
                "'. Make sure a recipe exists for this item with the specified production method or define a product override for an existing recipe with the specified production method in Module:Item recipe")
        end
 
        recipe_method = recipe_methods_lookup_match.method
        recipe_availability = recipe_methods_lookup_match.availability
    end
 
    -- extract products
 
    -- recipe product IDs mapped to amounts
    local recipe_products = {}
    if recipe.result then
        recipe_products[recipe.result] = 1
    elseif recipe.resultReagents then
        for product_id, amount in pairs(recipe.resultReagents) do
            recipe_products[product_id] = amount
        end
    else
        error("failed to generate a recipe for item: no products were found for recipe ID '" ..
            recipe.id ..
            "' (input query: '" ..
            input_query ..
            "'). This might be due to recipe having another way to describe products")
    end
 
    -- generate recipe element
 
    local recipe_el = mw.html.create("div")
        :addClass("item-recipe")
 
    if input_materials_only_layout == "vertical" or input_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 recipe ID '" ..
        recipe.id .. "' (input query: '" ..
        input_query ..
        "')")
 
    -- copy list of materials, but without costs
    local materials = {}
    for material, cost in pairs(recipe.materials) do
        table.insert(materials, material)
    end
 
    -- sort materials list based on material order config.
    -- materials not in config will come after the ordered materials.
    table.sort(materials, function(first, second)
        return (try_lookup_order_of_material(first) or 999999999999)
            < (try_lookup_order_of_material(second) or 999999999999)
    end)
 
    -- generate materials elements in sorted order
    for _, material in ipairs(materials) do
        local cost = recipe.materials[material]
 
        local material_item_id = itemModule.lookup_item_id(material, true)
        if material_item_id == nil then
            error("failed to generate a recipe for item: material '" ..
                material ..
                "' was not found in item registry (for recipe ID '" ..
                recipe.id .. "'; input query: '" ..
                input_query ..
                "'). Make sure that the material is added to the item name overrides in Module:Item")
        end
 
        materials_el:node(
            itemModule.generate_item {
                material_item_id,
                cost * input_amount
            }
        )
    end
 
    body_el:node(materials_el)
 
 
    if input_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)


-- [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]
        local product_and_method_container_el = mw.html.create("div")
            :addClass("item-recipe-product-and-method-container")


-- Amount of item. Default is 1.
        header_el:node(product_and_method_container_el)
-- 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 = nil


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


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


local current_frame = mw:getCurrentFrame()
        product_and_method_container_el:node(products_el)


local item_id = itemModule.lookup_item_id_by_name_and_amount{ [1] = item, [2] = amount }
local recipe_lookup_result = lookup_recipe_by_item_id_and_method(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") ..")")


local recipe = recipe_lookup_result.recipe
        for product_id, amount in pairs(recipe_products) do
local method = recipe_lookup_result.method
            local product_el = itemModule.generate_item {
                    [1] = product_id,
                    [2] = amount * input_amount,
                    cap = true
                }
                :addClass("item-recipe-product")
 
            products_el:node(product_el)
        end
 
 
        -- TODO: not all methods will be items, so this will eventually break.
        local method_el = methods_items_els_cache[recipe_method]
        if method_el == nil then
            method_el = mw.html.create("span")
                :addClass('item-recipe-method')
                :node(itemModule.generate_item { [1] = recipe_method, capitalize = true })
 
            methods_items_els_cache[recipe_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')
 
        if recipe.completetime == 0 then
            complete_time_el:node("Instant")
        else
            complete_time_el:node(format_seconds_to_short_string(recipe.completetime * input_amount))
        end
 
 
        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))')
 
        -- 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.
local recipe_el = mw.html.create("div")
-- Used to list all recipes for a particular method.
:addClass("item-recipe")
-- Recipes are sorted based on their products (the items display names).
function p.generate_list_of_recipes_for_method(frame)
if layout == "vertical" or layout == "ver" then
    local args = getArgs(frame)
recipe_el:addClass("item-recipe-vertical")
else
recipe_el:addClass("item-recipe-horizontal")
end


if materials_only then
    local method = args[1]
recipe_el:addClass("materials-only")
    assert_value_not_nil(method, "failed to generate a list of recipes for a method: method was not provided")
end
if not materials_only then
local product_el = mw.html.create("div")
:addClass("item-recipe-product")


product_el:node(itemModule.generate_item{ [1] = item_id, [2] = amount })
    -- Limit on how many recipes to generate
    local recipes_limit = tonumber(args.limit) or 99999999
recipe_el:node(product_el)


    local recipes = p.lookup_recipes_by_production_method(method)
local method_el = mw.html.create("div")
    assert_value_not_nil(recipes, "failed to generate a list of recipes for a method: unknown method: " .. method)
:addClass("item-recipe-method")


:node(mw.html.create("span"):addClass('recipe-supplementary-text'):node('is made on '))
    -- =======================
-- TODO: not all methods will be items, so this will eventually break.
:node(itemModule.generate_item{ [1] = method })
:node(mw.html.create("span"):addClass('recipe-supplementary-text'):node(' with'))


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


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


    --    if i == recipes_limit then
    --        break
    --    end


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


assert_value_not_nil(recipe.materials, "failed to generate a recipe for item: no 'materials' are specified for item " .. item_id .. " recipe (method: " .. method ..")")
    -- -- 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 = p.lookup_product_id_override(first, true)
    --        or first
    --    local second_item_id = p.lookup_product_id_override(second, true)
    --        or second


for material, cost in pairs(recipe.materials) do
    --    p.assert_product_exists(first_item_id)
materials_el:node(current_frame:preprocess("{{item|" .. material .. "|" .. (cost * amount) .. "}}"))
    --    p.assert_product_exists(second_item_id)
end


recipe_el:node(materials_el)
    --    return itemModule.lookup_item_name_by_item_id(first_item_id)
    --        < itemModule.lookup_item_name_by_item_id(second_item_id)
    -- end)


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


return recipe_el
    -- generate recipe elements
:allDone()
    for _, recipe in ipairs(recipes) do
        container_el:node(
            p.generate_item_recipe {
                [1] = recipe.id,
                [2] = 1,
                [3] = method
            }
        )
    end


    return container_el
        :allDone()
end
end


return p
return p

Latest revision as of 18:51, 16 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.

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 recipe IDs to recipes.
local recipes_by_recipe_ids = mw.loadJsonData("Module:Item recipe/recipes by recipe IDs.json")

-- A table mapping product IDs to recipe IDs.
-- A product ID can have either a single recipe ID mapped to it, or multiple if there's are multiple recipes.
local recipe_ids_by_product_ids = mw.loadJsonData("Module:Item recipe/recipe IDs by product IDs.json")

-- A table mapping production methods to recipe IDs, with a intermediate mapping by availability.
local recipe_ids_by_method_and_availability = mw.loadJsonData(
    "Module:Item recipe/recipe IDs by method and availability.json")

-- A table mapping material IDs (item IDs) to their display order.
-- Order is just a number. Materials with lesser order number will appear first.
local materials_order_by_material_ids = mw.loadJsonData("Module:Item recipe/order of materials.json")

-- A table remapping product IDs.
--
-- Not all recipes produce products that you might think they do -
-- some produce their own "printed" or "empty" or other variants.
--
-- For instance, a recipe for the small power cell produces `PowerCellSmallPrinted` item,
-- whereas the actual power cell item has ID `PowerCellSmall`.
--
-- So, for the module functions to be able 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 is done, a lookup for small power cell (or its id) will return the corresponding recipe.
local product_overrides = mw.loadJsonData("Module:Item recipe/product overrides.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 numeric_table_has_value(tab, val)
    for _, value in ipairs(tab) do
        if value == val then
            return true
        end
    end

    return false
end

local function filter_table(tab, predicate)
    local tab_filtered = {}
    for key, value in pairs(tab) do
        if predicate(key, value) then
            tab_filtered[key] = value
        end
    end

    return tab_filtered
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 map_numeric_table(tbl, f)
    local t = {}
    for i, v in ipairs(tbl) do
        t[i] = f(v, i)
    end
    return t
end
local function map_table(tbl, f)
    local t = {}
    for k, v in pairs(tbl) do
        t[k] = f(k, v)
    end
    return t
end

local function passthrough_assert_true(value, valueToReturnIfTrue, errorMessageOnFalse)
    if value then
        return valueToReturnIfTrue
    else
        error(errorMessageOnFalse)
    end
end

local function ternary_strict(valueToCheck, valueIfTrue, valueIfFalse)
    if valueToCheck == true then
        return valueIfTrue
    else
        return valueIfFalse
    end
end

-- formats seconds to a string `X min. Y sec.`.
--
-- - `X min.` part is omitted if there's less than a minute.
-- - `Y sec.` part is omitter if there's no seconds left.
local function format_seconds_to_short_string(input_seconds)
    local minutes = math.floor(input_seconds / 60)
    local seconds = input_seconds - minutes * 60

    local minutes_part = ternary_strict(minutes > 0, minutes .. " min.", nil)
    local seconds_part = ternary_strict(seconds > 0, seconds .. " sec.", nil)

    if minutes_part ~= nil and seconds_part ~= nil then
        return minutes_part .. " " .. seconds_part
    elseif seconds_part ~= nil then
        return seconds_part
    elseif minutes_part ~= nil then
        return minutes_part
    else
        return ''
    end
end

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

-- ######################
-- ### METHOD LOOKUPS ###
-- ######################

-- Lookups recipe IDs by production method `production_method`.
--
-- Returns recipe IDs grouped by availability.
--
-- Raises an error if no recipe IDs were found by the production method.
-- Set `no_error` to `true` to return `nil` instead.
function p.lookup_recipe_ids_and_availability_by_method(production_method, no_error)
    assert_value_not_nil(production_method)

    local method_item_id = p.lookup_method_item_id(production_method)

    return recipe_ids_by_method_and_availability[method_item_id]
        or passthrough_assert_true(
            no_error,
            nil,
            "recipe IDs by production method lookup failed: no recipes by production method '" ..
            method_item_id .. "' (input method: '" .. production_method .. "')"
        )
end

-- Lookups production methods by recipe ID `recipe_id`.
--
-- Returns an array of tables, each containg:
-- - `method` - production method.
-- - `availability` - availability for the recipe for this production method.
--
-- **NOTE:** This is an expensive function.
function p.lookup_methods_with_availability_by_recipe_id(inpit_recipe_id)
    local result = {}

    for method, recipe_ids_grouped in pairs(recipe_ids_by_method_and_availability) do
        for availability, recipe_ids in pairs(recipe_ids_grouped) do
            if numeric_table_has_value(recipe_ids, inpit_recipe_id) then
                table.insert(result, {
                    method = method,
                    availability = availability
                })
            end
        end
    end

    return result
end

-- Checks whether a production method `production_method` exists.
-- Any casing is allowed.
function p.method_exists(production_method)
    assert_value_not_nil(production_method)

    return p.lookup_recipe_ids_and_availability_by_method(production_method, true) ~= nil
end

-- Asserts that a production method `production_method` exists.
-- Any casing is allowed.
function p.assert_method_exists(production_method, custom_message)
    assert_value_not_nil(production_method)

    if not p.method_exists(production_method) then
        if custom_message then
            error("production method exists assertion failed for method '" ..
                production_method .. "': " .. custom_message)
        else
            error("production method exists assertion failed for method '" ..
                production_method .. "': production method is not defined")
        end
    end
end

-- Lookups method item ID.
--
-- Raises an error if no item ID was found.
-- Set `no_error` to `true` to return `nil` instead.
--
-- todo this will break in the future
function p.lookup_method_item_id(method, no_error)
    assert_value_not_nil(method)

    return itemModule.lookup_item_id(method, true)
        or passthrough_assert_true(
            no_error,
            nil,
            "production method item ID lookup failed: no item ID was found for method '" ..
            method .. "'"
        )
end

-- ################################
-- ### PRODUCT OVERRIDE LOOKUPS ###
-- ################################

-- Lookups an override for product ID `product_id`.
--
-- For instance, if there was an override `PowerCellSmallPrinted` that maps to to item ID `PowerCellSmall`,
-- this functions would return `PowerCellSmall` if `product_id` was `PowerCellSmallPrinted`.
--
-- Raises an error if no match was found. Set `no_error` to `true` to return `nil` instead.
function p.lookup_product_id_override(product_id, no_error)
    assert_value_not_nil(product_id)

    return product_overrides[product_id]
        or passthrough_assert_true(
            no_error,
            nil,
            "product override lookup failed: no override was found with product ID '" ..
            product_id .. "'"
        )
end

-- Lookups the prodct ID that was overriden with item ID `item_id`.
--
-- For instance, if there was an override `PowerCellSmallPrinted` that maps to to item ID `PowerCellSmall`,
-- this functions would return `PowerCellSmallPrinted` if `item_id` was `PowerCellSmall`.
--
-- **NOTE:** This is an expensive function.
--
-- Raises an error if no match was found. Set `no_error` to `true` to return `nil` instead.
function p.reverse_lookup_product_id_override(item_id, no_error)
    assert_value_not_nil(item_id)

    return find_first_table_item_key_matching_condition(
            product_overrides,
            function(_, value) return value == item_id end
        )
        or passthrough_assert_true(
            no_error,
            nil,
            "reverse product override lookup failed: no override was found that maps to item ID '" ..
            item_id .. "'"
        )
end

-- #######################
-- ### PRODUCT LOOKUPS ###
-- #######################

-- Checks whether a an item `product` exists.
-- Takes into account that `product` can have a product override.
function p.product_exsits(product)
    assert_value_not_nil(product)

    local product_with_override = p.lookup_product_id_override(product, true)
        or product

    return itemModule.item_exists(product_with_override)
end

-- Asserts that a product `product` exists.
-- `product` can be a product ID (including overriden ones), item ID or name.
function p.assert_product_exists(product, custom_message)
    assert_value_not_nil(product)

    if not p.product_exsits(product) then
        if custom_message then
            error("product exist assertion failed for product '" .. product .. "': " .. custom_message)
        else
            error("product exist assertion failed for product '" ..
                product ..
                "': no product was found. Make sure that a recipe exists with given product or that a product override is defined in Module:Item recipe")
        end
    end
end

-- Lookups recipe IDs by product ID `product_id`.
-- Accepts overriden `product_id`s.
--
-- Returns an array of matches.
--
-- **NOTE:** This is an expensive function.
function p.lookup_recipe_ids_by_product_id(product_id)
    assert_value_not_nil(product_id)

    local result = recipe_ids_by_product_ids[
    p.reverse_lookup_product_id_override(product_id, true)
    or product_id
    ]

    -- always returns an array
    if type(result) == 'string' then
        return { result }
    elseif result ~= nil then
        return result
    else
        return {}
    end
end

-- ######################
-- ### RECIPE LOOKUPS ###
-- ######################

-- Lookups recipe by recipe ID `recipe_id`.
--
-- Returns `nil` if no recipe was found.
-- Set `no_error` to `true` to return `nil` instead.
function p.lookup_recipe_by_recipe_id(recipe_id, no_error)
    assert_value_not_nil(recipe_id)

    return recipes_by_recipe_ids[recipe_id]
        or passthrough_assert_true(
            no_error,
            nil,
            "failed to lookup recipe by recipe id '" .. recipe_id .. "': no recipe was found"
        )
end

-- Asserts that a recipe ID `recipe_id` exists.
-- function assert_recipe_id_exists(recipe_id)
--     error("not impl")
-- end

-- Searches recipes by `query`. `query` can be an product ID (item ID - including overrden ones),
-- name or a recipe ID.
function p.search_recipes(query)
    assert_value_not_nil(query, "failed to lookup recipes by method and item ID: item ID was not provided")

    -- check if query is a recipe ID
    local recipe_by_recipe_id = recipes_by_recipe_ids[query]
    if recipe_by_recipe_id then
        -- if so - we got a direct match!
        return { recipe_by_recipe_id }
    end

    -- check if query is an item name/ID
    -- this is a last possibility.
    -- TODO add a custom errror here?
    local recipe_product_by_item_id_or_name = itemModule.lookup_item_id(query)

    local recipe_ids_by_item_id_or_name = p.lookup_recipe_ids_by_product_id(recipe_product_by_item_id_or_name)

    local recipes = {}
    if recipe_ids_by_item_id_or_name ~= nil then
        for _, recipe_id in ipairs(recipe_ids_by_item_id_or_name) do
            table.insert(recipes, p.lookup_recipe_by_recipe_id(recipe_id))
        end
    end

    return recipes
end

-- Lookups recipes by production method.
--
-- Raises an error if no recipes were found by the production method.
function p.lookup_recipes_by_production_method(method)
    assert_value_not_nil(method, "failed to lookup recipes by production method: no method was given")

    p.assert_method_exists(method,
        "failed to lookup recipes by production method: method '" .. method .. "' doesn't exist")

    local recipe_ids_grouped = p.lookup_recipe_ids_and_availability_by_method(method)

    local recipes = {}
    for availability, recipe_ids in pairs(recipe_ids_grouped) do
        for _, recipe_id in ipairs(recipe_ids) do
            local recipe = p.lookup_recipe_by_recipe_id(recipe_id)
            table.insert(recipes, recipe)
        end
    end

    return recipes
end

-- Filters given recipes by production method.
-- Any casing is allowed for production method.
-- function p.filter_recipes_by_production_method(recipes, production_method)
--     p.assert_method_exists(production_method)

--     return filter_table(
--         p.lookup_recipe_ids_by_method(production_method),
--         function(_, recipe_ids_grouped)
--             return find_first_numeric_table_item_matching_condition(
--                 recipes,
--                 function(recipe) return recipe.id == recipe_ids_grouped end
--             ) ~= nil
--         end
--     )
-- end

-- ##############################
-- ### MATERIAL ORDER LOOKUPS ###
-- ##############################

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

    local item_id = itemModule.lookup_item_id(material, true)

    if item_id == nil then
        error("failed to lookup order of material: material '" .. material .. "' does not exist")
    end

    return materials_order_by_material_ids[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, item ID or a recipe ID. Required.
    local input_query = args[1]
    assert_value_not_nil(input_query, "failed to generate a recipe for query: query 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 input_amount = nil_or(args[2], "1")

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


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

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

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

    -- search recipes

    local recipes = p.search_recipes(input_query)
    local recipes_count = numeric_table_length(recipes)
    if recipes_count == 0 then
        error("failed to generate a recipe for item: no recipe was found for item '" ..
            input_query ..
            "' (input method: '" ..
            (input_method or "nil") ..
            "'). Make sure a recipe exists for this item or define a product override for an existing recipe in Module:Item recipe")
    elseif recipes_count > 1 then
        error("failed to generate a recipe for item: found multiple recipes for item '" ..
            input_query ..
            "' (input method: '" ..
            (input_method or "nil") ..
            "'). Rendering multiple recipes is currently unsupported")
    end

    local recipe = recipes[1]

    -- search recipe methods

    local recipe_methods_lookup = p.lookup_methods_with_availability_by_recipe_id(recipe.id)
    local recipe_methods_count = table_length(recipe_methods_lookup)
    if recipe_methods_count == 0 then
        error("failed to generate a recipe for item: no methods were found for recipe ID '" ..
            recipe.id ..
            "' (input query: '" .. input_query .. "'; input method: '" ..
            (input_method or "nil") ..
            "'). This shouldn't usually happen because the present recipes are bound to some production methods. Probable cause: bug in the recipe generation code")
    elseif recipe_methods_count > 1 and input_method == nil then
        local methods = map_numeric_table(
            recipe_methods_lookup,
            function(match)
                return match.method
            end
        )

        error("failed to generate a recipe for item: found multiple production methods for recipe ID '" ..
            recipe.id ..
            "' (input query: '" ..
            input_query ..
            "') and input production method was NOT specified. Rendering multiple recipes is unsupported, so please specify a production method from available methods for this recipe: '" ..
            table.concat(methods, "', '") .. "'")
    end

    local recipe_method
    local recipe_availability
    if input_method == nil then
        -- if no input methods is specified, use the single available recipe method
        recipe_method = recipe_methods_lookup[1].method
        recipe_availability = recipe_methods_lookup[1].availability
    else
        -- otherwise, the number of available methods can vary,
        -- so filter it down to a single one based on the input method.

        local method_item_id = p.lookup_method_item_id(input_method)

        local recipe_methods_lookup_match = find_first_numeric_table_item_matching_condition(
            recipe_methods_lookup,
            function(lookup_result)
                return lookup_result.method == method_item_id
            end
        )

        if recipe_methods_lookup_match == nil then
            error("failed to generate a recipe for item: no production methods were found for recipe ID '" ..
                recipe.id ..
                "' (input query: '" ..
                input_query ..
                "') matching input method '" ..
                (input_method or "nil") ..
                "'. Make sure a recipe exists for this item with the specified production method or define a product override for an existing recipe with the specified production method in Module:Item recipe")
        end

        recipe_method = recipe_methods_lookup_match.method
        recipe_availability = recipe_methods_lookup_match.availability
    end

    -- extract products

    -- recipe product IDs mapped to amounts
    local recipe_products = {}
    if recipe.result then
        recipe_products[recipe.result] = 1
    elseif recipe.resultReagents then
        for product_id, amount in pairs(recipe.resultReagents) do
            recipe_products[product_id] = amount
        end
    else
        error("failed to generate a recipe for item: no products were found for recipe ID '" ..
            recipe.id ..
            "' (input query: '" ..
            input_query ..
            "'). This might be due to recipe having another way to describe products")
    end

    -- generate recipe element

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

    if input_materials_only_layout == "vertical" or input_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 recipe ID '" ..
        recipe.id .. "' (input query: '" ..
        input_query ..
        "')")

    -- copy list of materials, but without costs
    local materials = {}
    for material, cost in pairs(recipe.materials) do
        table.insert(materials, material)
    end

    -- sort materials list based on material order config.
    -- materials not in config will come after the ordered materials.
    table.sort(materials, function(first, second)
        return (try_lookup_order_of_material(first) or 999999999999)
            < (try_lookup_order_of_material(second) or 999999999999)
    end)

    -- generate materials elements in sorted order
    for _, material in ipairs(materials) do
        local cost = recipe.materials[material]

        local material_item_id = itemModule.lookup_item_id(material, true)
        if material_item_id == nil then
            error("failed to generate a recipe for item: material '" ..
                material ..
                "' was not found in item registry (for recipe ID '" ..
                recipe.id .. "'; input query: '" ..
                input_query ..
                "'). Make sure that the material is added to the item name overrides in Module:Item")
        end

        materials_el:node(
            itemModule.generate_item {
                material_item_id,
                cost * input_amount
            }
        )
    end

    body_el:node(materials_el)


    if input_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 products_el = mw.html.create("div")
            :addClass("item-recipe-products")

        product_and_method_container_el:node(products_el)


        for product_id, amount in pairs(recipe_products) do
            local product_el = itemModule.generate_item {
                    [1] = product_id,
                    [2] = amount * input_amount,
                    cap = true
                }
                :addClass("item-recipe-product")

            products_el:node(product_el)
        end


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

            methods_items_els_cache[recipe_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')

        if recipe.completetime == 0 then
            complete_time_el:node("Instant")
        else
            complete_time_el:node(format_seconds_to_short_string(recipe.completetime * input_amount))
        end


        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))')

        -- 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 = p.lookup_recipes_by_production_method(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 ipairs(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 = p.lookup_product_id_override(first, true)
    --         or first
    --     local second_item_id = p.lookup_product_id_override(second, true)
    --         or second

    --     p.assert_product_exists(first_item_id)
    --     p.assert_product_exists(second_item_id)

    --     return itemModule.lookup_item_name_by_item_id(first_item_id)
    --         < itemModule.lookup_item_name_by_item_id(second_item_id)
    -- end)

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

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

    return container_el
        :allDone()
end

return p