Module:Item recipe

From Space Station 14 Wiki
Revision as of 16:49, 8 September 2024 by Aliser (talk | contribs) (updated due to deps on Module:Item and its rework)
Module documentation
View or edit this documentation (about module documentation)

Implements {{item recipe}}.

JSON files

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

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

JSON files that are filled manually:

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

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

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

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

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

-- Not all recipes produce products that you might think they do -
-- some produce their own "printed" or "empty" or other variants.
--
-- Note that these mapping must be defined manually here.
--
-- 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, lookups by small power cell or its id will return corresponding recipe.
local product_overrides = mw.loadJsonData("Module:Item recipe/product overrides.json")

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

local current_frame = mw:getCurrentFrame()

local methods_items_els_cache = {}

local was_template_styles_tag_el_added = false

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


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

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

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

    return false
end

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

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

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

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

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

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

-- Searchs for recipe product in the product overrides table that maps to a an item with ID `item_id`.
--
-- For instance, for an override that maps recipe product `PowerCellSmallPrinted` to item ID `PowerCellSmall`,
-- this functions would return recipe product `PowerCellSmallPrinted` if given the item ID `PowerCellSmall`.
--
-- Returns `nil` if no match was found or `fallback_recipe_product`, if it was given.
local function try_lookup_override_recipe_product_by_item_id(item_id, fallback_recipe_product)
    local match = find_first_table_item_key_matching_condition(
        product_overrides,
        function(_, value) return value == item_id end
    )

    if match then
        return match
    elseif fallback_recipe_product then
        return fallback_recipe_product
    else
        return nil
    end
end

-- Searchs for an item ID in in the product overrides table that is mapped to from a product ID `product_item_id`.
--
-- For instance, for an override that maps recipe product `PowerCellSmallPrinted` to item ID `PowerCellSmall`,
-- this functions would return item ID `PowerCellSmall` based on recipe product `PowerCellSmallPrinted`.
--
-- Returns `nil` if no match was found or `fallback_item_id`, if it was given.
local function try_lookup_item_id_by_override_recipe_product(product_item_id, fallback_item_id)
    local match = product_overrides[product_item_id]

    if match then
        return match
    elseif fallback_item_id then
        return fallback_item_id
    else
        return nil
    end
end

-- Asserts that a given product item exists.
-- Includes items defined in the products override table.
local function assert_product_exists(product, skip_product_override_lookup)
    if not skip_product_override_lookup then
        product = try_lookup_item_id_by_override_recipe_product(product, product)
    end

    if not itemModule.item_exists(product) then
        error("no recipe was found for product item ID '" ..
        product ..
        "' (product lookup: '" ..
        item_id ..
        "'). Make sure that the product item exists with that name/ID or an override is defined in the item recipe module product overrides in Module:Item recipe")
    end
end

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

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

    -- lookup a product id based on existing overrides
    -- if there's none, use the same item ID
    item_id = try_lookup_override_recipe_product_by_item_id(item_id, item_id)

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

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

    return order_of_materials[item_id]
end

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

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

    return el
end

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

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

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

    -- [REQUIRED]

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

    -- [OPTIONAL]

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

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


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

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

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

    -- first, try to lookup the item ID in case the given item as a overriden product (for some reason).
    -- fallback to the same item in case there's no overrides.
    item = try_lookup_item_id_by_override_recipe_product(item, item)

    -- then, check that the item exists.
    -- at this point, it can either a name or an ID.
    -- skip product override lookup with an extra `true` param.
    -- in case items doesn't exist, this will give a pretty error message.
    assert_product_exists(item, true)

    -- finally, get the item ID.
    -- this is done after the assertion for purposes of a pretty error message in case a lookup fails.
    -- here we are guaranteed to have an item.
    local item_id = itemModule.lookup_item_id(item)

    -- for a recipe using the resulting item ID.
    local recipe_lookup_result = lookup_recipe_by_item_id(item_id, method)
    assert_value_not_nil(recipe_lookup_result,
        "failed to generate a recipe for item: no recipe found for item ID '" ..
        item_id ..
        "' (given item name: '" ..
        item ..
        "'; method: '" ..
        (method or "nil") ..
        "'). Make sure a recipe exists for this item or define a product override for an existing recipe in Module:Item recipe")

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


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

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


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


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

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

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

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

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

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


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

    body_el:node(materials_el)


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

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

        recipe_el:node(header_el)


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

        header_el:node(product_and_method_container_el)


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

        product_and_method_container_el:node(product_el)


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

            methods_items_els_cache[method] = method_el
        end

        product_and_method_container_el:node(method_el)


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

        header_el:node(info_icons_el)


        recipe_el:node(body_el)


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

        if recipe.completetime == 0 then
            complete_time_el:node("Instant")
        else
            complete_time_el:node((recipe.completetime * amount) .. " " .. "sec.")
        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))')


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

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

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

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

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

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

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

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

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

        recipe_el:node(notes_el)
    end


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

        was_template_styles_tag_el_added = true
    end

    return recipe_el
        :allDone()
end

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

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

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

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

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

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

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

        if i == recipes_limit then
            break
        end

        i = i + 1
    end

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

        assert_product_exists(first_item_id, true)
        assert_product_exists(second_item_id, true)

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

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

    return container_el
        :allDone()
end

return p