Module:Item recipe: Difference between revisions

From Space Station 14 Wiki
(added cache for materials, slightly changing the look of items. for pages with a log of recipes - up to 2x reduction in real time usage, up to 4x reduction in lua time usage)
(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)
 
(24 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.
-- Keys are production methods, values are tables containing the recipes.
local recipes_by_recipe_ids = mw.loadJsonData("Module:Item recipe/recipes by recipe IDs.json")
local recipe_groups = {
 
autolathe = mw.loadJsonData("Module:Item recipe/recipes by lathe/autolathe.json"),
-- A table mapping product IDs to recipe IDs.
protolathe = mw.loadJsonData("Module:Item recipe/recipes by lathe/protolathe.json")
-- 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 product_overrides = mw.loadJsonData("Module:Item recipe/product overrides.json")


Line 21: Line 40:
local methods_items_els_cache = {}
local methods_items_els_cache = {}


local materials_items_els_cache = {}
local was_template_styles_tag_el_added = false


-- ====================
-- ====================
Line 27: 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
 
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
end


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


return false
    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


Line 62: Line 92:
-- * 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, value in pairs(table) do
    for key, value in pairs(table) do
if condition(key, value, table) then
        if condition(key, value, table) then
return value
            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, value in pairs(table) do
    for key, value in pairs(table) do
if condition(key, value, 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


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


-- 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 -
-- ### METHOD LOOKUPS ###
-- some produce their own "printed" or "empty" or other variants.
-- ######################
 
-- 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.
--
--
-- Note that these mapping must be defined manually in the `product_overrides`.
-- Returns an array of matches.
--
--
-- For instance, a recipe for the small power cell produces `PowerCellSmallPrinted` item,
-- **NOTE:** This is an expensive function.
-- whereas the actual power cell item has ID `PowerCellSmall`.
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`.
--
--
-- So, to find the recipe for the small power cell, we first would need to define a mapping from
-- Returns `nil` if no recipe was found.
-- the recipe product `PowerCellSmallPrinted` to the actual item `PowerCellSmall`.
-- Set `no_error` to `true` to return `nil` instead.
-- After that, this this function can be used to get the actual items based on the recipe product.
function p.lookup_recipe_by_recipe_id(recipe_id, no_error)
local function lookup_recipe_product_by_item_id(product_item_id)
    assert_value_not_nil(recipe_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 recipes_by_recipe_ids[recipe_id]
return match
        or passthrough_assert_true(
else
            no_error,
return product_item_id
            nil,
end
            "failed to lookup recipe by recipe id '" .. recipe_id .. "': no recipe was found"
        )
end
end


-- todo desc
-- Asserts that a recipe ID `recipe_id` exists.
local function lookup_item_id_by_recipe_product(product_item_id)
-- function assert_recipe_id_exists(recipe_id)
local match = product_overrides[product_item_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)


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


-- Searches a recipe of a given item.
-- Lookups recipes by production method.
-- 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.
-- Raises an error if no recipes were found by the production method.
local function lookup_recipe_by_item_id(item_id, production_method)
function p.lookup_recipes_by_production_method(method)
assert_value_not_nil(item_id, "failed to lookup recipes by method and item ID: item ID was not provided")
    assert_value_not_nil(method, "failed to lookup recipes by production method: no method was given")


-- generate a list of production methods to search across
    p.assert_method_exists(method,
local methods_to_lookup = {}
        "failed to lookup recipes by production method: method '" .. method .. "' doesn't exist")
if production_method == nil then
-- no method specified = search across all methods
for recipe_group_method, _ in pairs(recipe_groups) do
table.insert(methods_to_lookup, recipe_group_method)
end
else
-- method specified = only look through recipes with that production method
table.insert(methods_to_lookup, production_method)
end


-- apply a product override if needed
    local recipe_ids_grouped = p.lookup_recipe_ids_and_availability_by_method(method)
item_id = lookup_recipe_product_by_item_id(item_id)


-- do the search
    local recipes = {}
for _, production_method in ipairs(methods_to_lookup) do
    for availability, recipe_ids in pairs(recipe_ids_grouped) do
local match = recipe_groups[production_method][item_id]
        for _, recipe_id in ipairs(recipe_ids) do
if match then
            local recipe = p.lookup_recipe_by_recipe_id(recipe_id)
return {
            table.insert(recipes, recipe)
production_method = production_method,
        end
recipe = match
    end
}
 
end
    return recipes
end
end
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.
-- Produces a "note" element used in generation of item recipes.
-- Takes in a CSS-compatible color and text content.
-- Takes in a CSS-compatible color and text content.
local function generate_note_element(color, text)
local function generate_note_element(color, text)
local el = mw.html.create('span')
    local el = mw.html.create('span')
:addClass("item-recipe-note")
        :addClass("item-recipe-note")
:node(text)
        :node(text)


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


return el
    return el
end
end


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


Line 211: Line 526:
-- This is the main external function of this module.
-- 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
        )


-- [REQUIRED]
        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


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


-- [OPTIONAL]
    -- extract products


-- Amount of item. Default is 1.
    -- recipe product IDs mapped to amounts
-- Must be a string since Module:Item uses string amount.
    local recipe_products = {}
-- All values from templates come as strings.
    if recipe.result then
local amount = nil_or(args[2], "1")
        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


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


-- Recipe layout.
    local recipe_el = mw.html.create("div")
local layout = args["layout"] or args["lay"] or "horizontal"
        :addClass("item-recipe")


-- Whether to only generate a materials block.
    if input_materials_only_layout == "vertical" or input_materials_only_layout == "ver" then
local materials_only = yesNo(args["materials only"] or args["mat only"] or false)
        recipe_el:addClass("item-recipe-materials-layout-vertical")
    else
        recipe_el:addClass("item-recipe-materials-layout-horizontal")
    end


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


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


local recipe = recipe_lookup_result.recipe
    local materials_el = mw.html.create("div")
local method = recipe_lookup_result.production_method
        :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 ..
        "')")


local recipe_el = mw.html.create("div")
    -- copy list of materials, but without costs
:addClass("item-recipe")
    local materials = {}
:node(current_frame:extensionTag("templatestyles", "", { src = 'Template:Item recipe/styles.css' }))
    for material, cost in pairs(recipe.materials) do
        table.insert(materials, material)
    end


if layout == "vertical" or layout == "ver" then
    -- sort materials list based on material order config.
recipe_el:addClass("item-recipe-vertical")
    -- materials not in config will come after the ordered materials.
else
    table.sort(materials, function(first, second)
recipe_el:addClass("item-recipe-horizontal")
        return (try_lookup_order_of_material(first) or 999999999999)
end
            < (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 body_el = mw.html.create("span")
        local material_item_id = itemModule.lookup_item_id(material, true)
:addClass('item-recipe-body')
        if material_item_id == nil then
:css('background', 'linear-gradient(135deg, var(--bg-color-light-x2), var(--bg-color-light))')
            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


local materials_el = mw.html.create("div")
    body_el:node(materials_el)
: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 .. ")")


for material, cost in pairs(recipe.materials) do
    if input_materials_only then
local material_item_el = materials_items_els_cache[material]
        recipe_el:addClass("materials-only")
if material_item_el == nil then
material_item_el = itemModule.generate_item { material }
materials_items_els_cache[material] = material_item_el
end


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


body_el:node(materials_el)
        recipe_el:node(header_el)




if materials_only then
        local product_and_method_container_el = mw.html.create("div")
recipe_el:addClass("materials-only")
            :addClass("item-recipe-product-and-method-container")


recipe_el:node(body_el)
        header_el:node(product_and_method_container_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 products_el = mw.html.create("div")
            :addClass("item-recipe-products")


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


header_el:node(product_and_method_container_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")


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


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[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 })


-- TODO: not all methods will be items, so this will eventually break.
            methods_items_els_cache[recipe_method] = method_el
local method_el = methods_items_els_cache[method]
        end
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
        product_and_method_container_el:node(method_el)
end
product_and_method_container_el:node(method_el)




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


header_el:node(info_icons_el)
        header_el:node(info_icons_el)




recipe_el:node(body_el)
        recipe_el:node(body_el)




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


body_el:node(complete_time_el)
        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




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


if recipe.availability 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
        local notes_el = mw.html.create("div")
recipe_el:addClass('item-recipe-by-research')
            :addClass("item-recipe-notes")
recipe_el:addClass('item-recipe-by-emag')
            :css('background', 'linear-gradient(40deg, var(--bg-color-light-x3), var(--bg-color-light-x2))')


info_icons_el:node(generate_info_icon(current_frame, 'research'))
        -- if recipe is not available by default,
info_icons_el:node(generate_info_icon(current_frame, 'progression-symbol'))
        -- generate "info icons" and notes (if needed) telling about it.
info_icons_el:node(generate_info_icon(current_frame, 'emag'))
        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


notes_el:node(
            if is_recipe_unlocked_by_research_and_then_emag then
generate_note_element(
                recipe_el:addClass('item-recipe-by-research')
nil,
                recipe_el:addClass('item-recipe-by-emag')
current_frame:preprocess(
"'''This recipe is unlocked by <span style='color: var(--danger-color);'>[[Cryptographic Sequencer|{{item|EmagUnlimited|l=EMAG}}]]</span> after it has been <span style='color: gold;'>researched<span>'''")
)
)
elseif is_recipe_unlocked_by_research then
recipe_el:addClass('item-recipe-by-research')


info_icons_el:node(generate_info_icon(current_frame, 'research'))
                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'))


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


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


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


recipe_el:node(notes_el)
                info_icons_el:node(generate_info_icon(current_frame, 'emag'))
end


return recipe_el
                -- if not is_recipe_unlocked_by_research_and_then_emag then
:allDone()
                -- 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
end


Line 407: Line 860:
-- Recipes are sorted based on their products (the items display names).
-- Recipes are sorted based on their products (the items display names).
function p.generate_list_of_recipes_for_method(frame)
function p.generate_list_of_recipes_for_method(frame)
local args = getArgs(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 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 container_el = mw.html.create("div")
local recipes_limit = tonumber(args.limit) or 99999999
        :addClass("item-recipes-list")


local recipes = recipe_groups[method]
    -- -- generate a list of products
assert_value_not_nil(recipes, "failed to generate a list of recipes for a method: unknown method: " .. method)
    -- 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 container_el = mw.html.create("div")
    --    i = i + 1
:addClass("item-recipes-list")
    -- end


-- generate a list of products
    -- -- sort the list of products alphabetically
local products = {}
    -- -- by looking up the item names and comparing them
local i = 1
    -- table.sort(products, function(first, second)
for product, _ in pairs(recipes) do
    --    local first_item_id = p.lookup_product_id_override(first, true)
table.insert(products, product)
    --        or first
    --    local second_item_id = p.lookup_product_id_override(second, true)
    --        or second


if i == recipes_limit then
    --    p.assert_product_exists(first_item_id)
break
    --    p.assert_product_exists(second_item_id)
end


i = i + 1
    --    return itemModule.lookup_item_name_by_item_id(first_item_id)
end
    --        < itemModule.lookup_item_name_by_item_id(second_item_id)
    -- end)


-- sort the list of products alphabetically
    -- -- generate recipe elements
-- by looking up the item names and comparing them
    -- for _, product in ipairs(products) do
table.sort(products, function(first, second)
    --    container_el:node(p.generate_item_recipe {
return itemModule.lookup_item_name_by_id({ [1] = lookup_item_id_by_recipe_product(first) })
    --        [1] = p.lookup_product_id_override(product),
< itemModule.lookup_item_name_by_id({ [1] = lookup_item_id_by_recipe_product(second) })
    --        [2] = 1,
end)
    --        [3] = method
    --    })
    -- end


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


return container_el
    return container_el
:allDone()
        :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