Module:Item recipe: Difference between revisions

From Space Station 14 Wiki
(better error log)
(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)
 
(12 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')


-- A table mapping production methods to recipes that a given method can produce.
-- A table mapping recipe IDs to recipes.
local recipes_by_method = mw.loadJsonData("Module:Item recipe/recipes/lathes.json")
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 product_overrides = mw.loadJsonData("Module:Item recipe/product overrides.json")
local order_of_materials = mw.loadJsonData("Module:Item recipe/order of materials.json")


local current_frame = mw:getCurrentFrame()
local current_frame = mw:getCurrentFrame()
Line 25: 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 60: 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`.
--
--
-- Note that these mapping must be defined manually in the `product_overrides`.
-- 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`.
--
--
-- 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`.
--
--
-- So, to find the recipe for the small power cell, we first would need to define a mapping from
-- Raises an error if no match was found. Set `no_error` to `true` to return `nil` instead.
-- the recipe product `PowerCellSmallPrinted` to the actual item `PowerCellSmall`.
function p.reverse_lookup_product_id_override(item_id, no_error)
-- After that, this this function can be used to get the actual items based on the recipe product.
    assert_value_not_nil(item_id)
local function lookup_recipe_product_by_item_id(product_item_id)
local match = find_first_table_item_key_matching_condition(
product_overrides,
function(key, value) return value == product_item_id end
)


if match then
    return find_first_table_item_key_matching_condition(
return match
            product_overrides,
else
            function(_, value) return value == item_id end
return product_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
end


-- todo desc
-- #######################
local function lookup_item_id_by_recipe_product(product_item_id)
-- ### PRODUCT LOOKUPS ###
local match = product_overrides[product_item_id]
-- #######################
 
-- 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


if match then
    return itemModule.item_exists(product_with_override)
return match
else
return product_item_id
end
end
end


-- Searches a recipe of a given item.
-- Asserts that a product `product` exists.
-- If `production_method` is specified, only looks through the recipes with that method,
-- `product` can be a product ID (including overriden ones), item ID or name.
-- otherwise searching across all methods until a match is found.
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 table with `production_method` and `recipe`, or `nil` if no recipe was found.
-- Returns an array of matches.
local function lookup_recipe_by_item_id(item_id, production_method)
--
assert_value_not_nil(item_id, "failed to lookup recipes by method and item ID: item ID was not provided")
-- **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


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


-- apply a product override if needed
-- Lookups recipe by recipe ID `recipe_id`.
item_id = lookup_recipe_product_by_item_id(item_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)


-- do the search
    return recipes_by_recipe_ids[recipe_id]
for _, production_method in ipairs(methods_to_lookup) do
        or passthrough_assert_true(
local match = recipes_by_method[production_method][item_id]
            no_error,
if match then
            nil,
return {
            "failed to lookup recipe by recipe id '" .. recipe_id .. "': no recipe was found"
production_method = production_method,
        )
recipe = match
}
end
end
end
end


-- Searches a material item in the material order config, returning the order number or `nil`,
-- Asserts that a recipe ID `recipe_id` exists.
-- if the config doesn't have the queried item.
-- function assert_recipe_id_exists(recipe_id)
local function lookup_order_of_material(item_id)
--    error("not impl")
assert_value_not_nil(item_id, "failed to lookup order of material: item ID was not provided")
-- 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 order_of_materials[item_id]
    return recipes
end
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.
-- 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 218: 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]
    -- [REQUIRED]


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


-- [OPTIONAL]
    -- [OPTIONAL]


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


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




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


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


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


local item_id = itemModule.try_lookup_item_id(item)
    -- search recipes


local recipe_lookup_result = lookup_recipe_by_item_id(item_id, method)
    local recipes = p.search_recipes(input_query)
assert_value_not_nil(recipe_lookup_result,
    local recipes_count = numeric_table_length(recipes)
"failed to generate a recipe for item: no recipe found for item " ..
    if recipes_count == 0 then
item_id .. " (method: " .. (method or "nil") .. "; original item name: ".. item ..")")
        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 = recipe_lookup_result.recipe
    local recipe = recipes[1]
local method = recipe_lookup_result.production_method


    -- search recipe methods


local recipe_el = mw.html.create("div")
    local recipe_methods_lookup = p.lookup_methods_with_availability_by_recipe_id(recipe.id)
:addClass("item-recipe")
    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
        )


if materials_only_layout == "vertical" or materials_only_layout == "ver" then
        error("failed to generate a recipe for item: found multiple production methods for recipe ID '" ..
recipe_el:addClass("item-recipe-materials-layout-vertical")
            recipe.id ..
else
            "' (input query: '" ..
recipe_el:addClass("item-recipe-materials-layout-horizontal")
            input_query ..
end
            "') 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 body_el = mw.html.create("div")
        local method_item_id = p.lookup_method_item_id(input_method)
:addClass('item-recipe-body')
:css('background', 'linear-gradient(135deg, var(--bg-color-light-x2), var(--bg-color-light))')


        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
        )


local materials_el = mw.html.create("div")
        if recipe_methods_lookup_match == nil then
:addClass("item-recipe-materials")
            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


assert_value_not_nil(recipe.materials,
        recipe_method = recipe_methods_lookup_match.method
"failed to generate a recipe for item: no 'materials' are specified for item " ..
        recipe_availability = recipe_methods_lookup_match.availability
item_id .. " recipe (method: " .. method .. ")")
    end


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


-- sort materials list based on material order config
    -- recipe product IDs mapped to amounts
-- if item doesn't have an order, use a big number as its order, placing them after
    local recipe_products = {}
-- materials that have an order defined
    if recipe.result then
table.sort(materials_item_ids, function(first, second)
        recipe_products[recipe.result] = 1
return (lookup_order_of_material(first) or 999999999999)
    elseif recipe.resultReagents then
< (lookup_order_of_material(second) or 999999999999)
        for product_id, amount in pairs(recipe.resultReagents) do
end)
            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


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


materials_el:node(itemModule.generate_item { material, cost })
    local recipe_el = mw.html.create("div")
end
        :addClass("item-recipe")


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




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


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


local product_and_method_container_el = mw.html.create("div")
    -- copy list of materials, but without costs
:addClass("item-recipe-product-and-method-container")
    local materials = {}
    for material, cost in pairs(recipe.materials) do
        table.insert(materials, material)
    end


header_el:node(product_and_method_container_el)
    -- 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 product_el = itemModule.generate_item { [1] = item_id, [2] = amount, cap = true }
        local material_item_id = itemModule.lookup_item_id(material, true)
:addClass("item-recipe-product")
        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


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


    body_el:node(materials_el)


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


methods_items_els_cache[method] = method_el
    if input_materials_only then
end
        recipe_el:addClass("materials-only")


product_and_method_container_el:node(method_el)
        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 info_icons_el = mw.html.create("div")
:addClass("item-recipe-info-icons")


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


recipe_el:node(body_el)


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


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


body_el:node(complete_time_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 notes_el = mw.html.create("div")
            products_el:node(product_el)
:addClass("item-recipe-notes")
        end
:css('background', 'linear-gradient(40deg, var(--bg-color-light-x3), var(--bg-color-light-x2))')




assert_value_not_nil(recipe.availability, "failed to generate a recipe for item '" .. item_id .. "': recipe doesn't have its 'availability defined")
        -- 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 })


-- if recipe is not available by default,
            methods_items_els_cache[recipe_method] = method_el
-- generate "info icons" and notes (if needed) telling about it.
        end
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
        product_and_method_container_el:node(method_el)
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(
        local info_icons_el = mw.html.create("div")
generate_note_element(
            :addClass("item-recipe-info-icons")
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'))
        header_el:node(info_icons_el)


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


-- 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)
        local complete_time_el = mw.html.create("span")
end
            :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


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


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


Line 432: 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 method = args[1]
    local recipes = p.lookup_recipes_by_production_method(method)
assert_value_not_nil(method, "failed to generate a list of recipes for a method: method was not provided")
    assert_value_not_nil(recipes, "failed to generate a list of recipes for a method: unknown method: " .. method)


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


local recipes = recipes_by_method[itemModule.try_lookup_item_id(method)]
    local container_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-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)


local container_el = mw.html.create("div")
    --    if i == recipes_limit then
:addClass("item-recipes-list")
    --        break
    --     end


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


if i == recipes_limit then
    -- -- sort the list of products alphabetically
break
    -- -- by looking up the item names and comparing them
end
    -- 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


i = i + 1
    --    p.assert_product_exists(first_item_id)
end
    --    p.assert_product_exists(second_item_id)


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


return itemModule.lookup_item_name_by_id(first_actual_item_id)
    -- -- generate recipe elements
< itemModule.lookup_item_name_by_id(second_actual_item_id)
    -- for _, product in ipairs(products) do
end)
    --    container_el:node(p.generate_item_recipe {
    --        [1] = p.lookup_product_id_override(product),
    --        [2] = 1,
    --        [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