Module:Item recipe: Difference between revisions

From Space Station 14 Wiki
(wip)
(removed custom reagents handlers in favor of default logic; fixed a bug where lookup_recipe_ids_by_product_id() wasn't always returning an array; itty-bitty refactor)
 
(71 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Contains utilities for working with in-game item recipes.
-- Contains utilities for working with in-game item recipes.
-- todo: material sorting. based on alphabetical sorting? maybe at .json generation step, convert materials to an array?


local p = {} --p stands for package
local p = {} --p stands for package
Line 8: Line 6:
local yesNo = require('Module:Yesno')
local yesNo = require('Module:Yesno')


-- An array of recipe groups.
-- A table mapping recipe IDs to recipes.
-- Recipes are grouped by `method`.
local recipes_by_recipe_ids = mw.loadJsonData("Module:Item recipe/recipes by recipe IDs.json")
-- Note that the order matters for perfomance - the recipe lookup happens from top to bottom.
local recipe_groups = {
{ method = "autolathe", recipes = mw.loadJsonData("Module:Item recipe/recipes by lathe/autolathe.json") },
{ method = "protolathe", recipes = mw.loadJsonData("Module:Item recipe/recipes by lathe/protolathe.json") },
}


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


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


-- A table containing item recipe categories, identified by recipe category IDs.
-- A table mapping material IDs (item IDs) to their display order.
-- local recipy_categories_by_recipe_category_id = mw.loadJsonData("Module:Item recipe/recipy categories by recipe category id.json")
-- 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


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


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


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


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


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


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


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


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


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


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


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


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


if recipe_group == nil then
    local recipes = {}
error("failed to get recipes by method: no such recipe group with method " .. method)
    if recipe_ids_by_item_id_or_name ~= nil then
end
        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 recipe_group.recipes
    return recipes
end
end


-- Given an item ID as a some recipe product, lookups it in the prduct overrides.
-- Lookups recipes by production method.
-- On a match, returns a new item ID to use as a product.
--
local function resolve_recipe_product_override_if_needed(product_item_id)
-- Raises an error if no recipes were found by the production method.
local match_override_product = find_first_table_item_matching_condition(
function p.lookup_recipes_by_production_method(method)
recipes_products_overrides,  
    assert_value_not_nil(method, "failed to lookup recipes by production method: no method was given")
function (override_product, recipe_product) return recipe_product == product_item_id end
 
)
    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")


if match_override_product == nil then
    local item_id = itemModule.lookup_item_id(material, true)
return product_item_id
 
else
    if item_id == nil then
return match_override_product
        error("failed to lookup order of material: material '" .. material .. "' does not exist")
end
    end
 
    return materials_order_by_material_ids[item_id]
end
end


-- Searches a recipe for a given item, optionally with a specific production method.
-- =======================
-- @returns A table with `method` and `recipe`, or `nil` if no recipe was found.
local function lookup_recipe_by_item_id_and_method(item_id, method)
assert_value_not_nil(item_id, "failed to lookup recipes by method and item ID: item ID was not provided")


-- production methods to lookup through
-- Produces a "note" element used in generation of item recipes.
local methods_to_lookup = {}
-- Takes in a CSS-compatible color and text content.
if method == nil then
local function generate_note_element(color, text)
-- no method specified = look through all methods until the recipe is found
    local el = mw.html.create('span')
for _, recipe_group in ipairs(recipe_groups) do
        :addClass("item-recipe-note")
table.insert(methods_to_lookup, recipe_group.method)
        :node(text)
end
else
-- method specified = only look through recipes with that production method
table.insert(methods_to_lookup, method)
end


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


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


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


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


-- [OPTIONAL]
        local complete_time_el = mw.html.create("span")
            :addClass('item-recipe-complete-time')


-- Amount of item. Default is 1.
        if recipe.completetime == 0 then
-- Must be a string since Module:Item uses string amount.
            complete_time_el:node("Instant")
-- All values from templates come as strings.
        else
local amount = nil_or(args[2], "1")
            complete_time_el:node(format_seconds_to_short_string(recipe.completetime * input_amount))
        end
-- Item production method. Can be "nil", in which case it's looked up.
local method = nil


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


-- ============
        body_el:node(complete_time_el)


local current_frame = mw:getCurrentFrame()


local item_id = itemModule.lookup_item_id_by_name_and_amount{ [1] = item, [2] = amount }
        local notes_el = mw.html.create("div")
            :addClass("item-recipe-notes")
local recipe_lookup_result = lookup_recipe_by_item_id_and_method(item_id, method)
            :css('background', 'linear-gradient(40deg, var(--bg-color-light-x3), var(--bg-color-light-x2))')
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
        -- if recipe is not available by default,
local method = recipe_lookup_result.method
        -- 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
local recipe_el = mw.html.create("div")
                recipe_el:addClass('item-recipe-by-research')
:addClass("item-recipe")
                recipe_el:addClass('item-recipe-by-emag')
:node(current_frame:extensionTag("templatestyles", "", { src = 'Template:Item/styles.css' }))
if layout == "vertical" or layout == "ver" then
recipe_el:addClass("item-recipe-vertical")
else
recipe_el:addClass("item-recipe-horizontal")
end


if materials_only then
                info_icons_el:node(generate_info_icon(current_frame, 'research'))
recipe_el:addClass("materials-only")
                info_icons_el:node(generate_info_icon(current_frame, 'progression-symbol'))
end
                info_icons_el:node(generate_info_icon(current_frame, 'emag'))
if not materials_only then
local product_el = mw.html.create("div")
:addClass("item-recipe-product")


product_el:node(itemModule.generate_item{ [1] = item_id, [2] = amount })
                notes_el:node(
                    generate_note_element(
recipe_el:node(product_el)
                        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'))
local method_el = mw.html.create("div")
:addClass("item-recipe-method")


:node(mw.html.create("span"):addClass('recipe-supplementary-text'):node('is made on '))
                -- if not is_recipe_unlocked_by_research_and_then_emag then
                -- notes_el:node(
-- TODO: not all methods will be items, so this will eventually break.
                -- generate_note_element(
:node(itemModule.generate_item{ [1] = method })
                -- "gold",
                -- "'''This recipe is unlocked by research'''"
:node(mw.html.create("span"):addClass('recipe-supplementary-text'):node(' with'))
                -- )
                -- )
                -- 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


recipe_el:node(method_el)
-- 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")


local notes_el = mw.html.create("div")
    -- Limit on how many recipes to generate
:addClass("item-recipe-notes")
    local recipes_limit = tonumber(args.limit) or 99999999


if recipe.latheRecipeType then
    local recipes = p.lookup_recipes_by_production_method(method)
if string.find(recipe.latheRecipeType, "dynamic", 1, true) ~= nil then
    assert_value_not_nil(recipes, "failed to generate a list of recipes for a method: unknown method: " .. method)
local researchable_recipe_notice_el = mw.html.create('span')
:node("This recipe must be researched first")
:css('color', 'gold')


notes_el:node(researchable_recipe_notice_el)
    -- =======================
end


if string.find(recipe.latheRecipeType, "emag", 1, true) ~= nil then
    local container_el = mw.html.create("div")
local researchable_recipe_notice_el = mw.html.create('span')
        :addClass("item-recipes-list")
:wikitext("This recipe is only available by [[Cryptographic Sequencer|EMAG]]")
:css('color', 'magenta')
notes_el:node(researchable_recipe_notice_el)
end
end


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


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


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


assert_value_not_nil(recipe.materials, "failed to generate a recipe for item: no 'materials' are specified for item " .. item_id .. " recipe (method: " .. method ..")")
    -- -- sort the list of products alphabetically
    -- -- by looking up the item names and comparing them
    -- table.sort(products, function(first, second)
    --    local first_item_id = p.lookup_product_id_override(first, true)
    --        or first
    --    local second_item_id = p.lookup_product_id_override(second, true)
    --        or second


for material, cost in pairs(recipe.materials) do
    --    p.assert_product_exists(first_item_id)
materials_el:node(itemModule.generate_item{ [1] = material, [2] = cost * amount })
    --    p.assert_product_exists(second_item_id)
end


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


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


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


    return container_el
        :allDone()
end
end


return p
return p

Latest revision as of 18:51, 16 September 2024

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

Implements {{item recipe}}.

JSON files

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

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

JSON files that are filled manually:

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

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

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

-- A table mapping recipe IDs to recipes.
local recipes_by_recipe_ids = mw.loadJsonData("Module:Item recipe/recipes by recipe IDs.json")

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

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

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

-- A table remapping product IDs.
--
-- Not all recipes produce products that you might think they do -
-- some produce their own "printed" or "empty" or other variants.
--
-- For instance, a recipe for the small power cell produces `PowerCellSmallPrinted` item,
-- whereas the actual power cell item has ID `PowerCellSmall`.
--
-- So, for the module functions to be able to find the recipe for the small power cell,
-- we first would need to define a mapping from the recipe product `PowerCellSmallPrinted`
-- to the actual item `PowerCellSmall`.
--
-- After that is done, a lookup for small power cell (or its id) will return the corresponding recipe.
local product_overrides = mw.loadJsonData("Module:Item recipe/product overrides.json")

local current_frame = mw:getCurrentFrame()

local methods_items_els_cache = {}

local was_template_styles_tag_el_added = false

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


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

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

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

    return false
end

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

    return tab_filtered
end

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

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

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

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

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

local function map_numeric_table(tbl, f)
    local t = {}
    for i, v in ipairs(tbl) do
        t[i] = f(v, i)
    end
    return t
end
local function map_table(tbl, f)
    local t = {}
    for k, v in pairs(tbl) do
        t[k] = f(k, v)
    end
    return t
end

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

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

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

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

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

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

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

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

    local method_item_id = p.lookup_method_item_id(production_method)

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

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

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

    return result
end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    return itemModule.item_exists(product_with_override)
end

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

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

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

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

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

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

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

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

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

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

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

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

    local recipe_ids_by_item_id_or_name = p.lookup_recipe_ids_by_product_id(recipe_product_by_item_id_or_name)

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

    return recipes
end

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

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

    local recipe_ids_grouped = p.lookup_recipe_ids_and_availability_by_method(method)

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

    return recipes
end

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

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

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

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

    local item_id = itemModule.lookup_item_id(material, true)

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

    return materials_order_by_material_ids[item_id]
end

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

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

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

    return el
end

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

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

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

    -- [REQUIRED]

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

    -- [OPTIONAL]

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

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


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

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

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

    -- search recipes

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

    local recipe = recipes[1]

    -- search recipe methods

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

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

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

        local method_item_id = p.lookup_method_item_id(input_method)

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

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

        recipe_method = recipe_methods_lookup_match.method
        recipe_availability = recipe_methods_lookup_match.availability
    end

    -- extract products

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

    -- generate recipe element

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

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


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


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

    assert_value_not_nil(recipe.materials,
        "failed to generate a recipe for item: no 'materials' are specified for recipe ID '" ..
        recipe.id .. "' (input query: '" ..
        input_query ..
        "')")

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

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

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

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

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

    body_el:node(materials_el)


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

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

        recipe_el:node(header_el)


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

        header_el:node(product_and_method_container_el)


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

        product_and_method_container_el:node(products_el)


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

            products_el:node(product_el)
        end


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

            methods_items_els_cache[recipe_method] = method_el
        end

        product_and_method_container_el:node(method_el)


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

        header_el:node(info_icons_el)


        recipe_el:node(body_el)


        local complete_time_el = mw.html.create("span")
            :addClass('item-recipe-complete-time')

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


        body_el:node(complete_time_el)


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

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

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

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

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

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

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

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

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

        recipe_el:node(notes_el)
    end


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

        was_template_styles_tag_el_added = true
    end

    return recipe_el
        :allDone()
end

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

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

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

    local recipes = p.lookup_recipes_by_production_method(method)
    assert_value_not_nil(recipes, "failed to generate a list of recipes for a method: unknown method: " .. method)

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

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

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

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

    --     i = i + 1
    -- end

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

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

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

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

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

    return container_el
        :allDone()
end

return p