Module:Item/recipes

From Path of Exile 2 Wiki
Revision as of 20:52, 25 September 2024 by Vinifera7 (talk | contribs)
Jump to navigation Jump to search
Module documentation[view] [edit] [history] [purge]


Lua logo

This module depends on the following other modules:

This submodule of Module:Item contains configuration and functions for item recipes.

-------------------------------------------------------------------------------
-- 
-- Recipes for Module:Item2
-- 
-------------------------------------------------------------------------------

local m_util = require('Module:Util')
local m_cargo = require('Module:Cargo')

local m_game = mw.loadData('Module:Game')

-- Lazy loading
local f_modifier_link -- require('Module:Modifier link').modifier_link

-- Should we use the sandbox version of our submodules?
local use_sandbox = m_util.misc.maybe_sandbox('Item2')

-- The cfg table contains all localisable strings and configuration, to make it
-- easier to port this module to another wiki.
local cfg = use_sandbox and mw.loadData('Module:Item2/config/sandbox') or mw.loadData('Module:Item2/config')

local i18n = cfg.i18n.recipes

-- ----------------------------------------------------------------------------
-- Helper functions 
-- ----------------------------------------------------------------------------

local h = {}

-- Lazy loading for Module:Modifier link
function h.modifier_link(args)
    if not f_modifier_link then
        f_modifier_link = require('Module:Modifier link').main
    end
    return f_modifier_link(args)
end

h.conditions = {}
h.conditions.factory = {}

function h.conditions.factory.arg(args)
    -- Required:
    --  arg: The argument to check against
    --  One must be specified
    --   value: check whether the argument equals this value
    --   values: check whether the argument is in this list of values
    --   values_assoc: check whether the argument is in this associative table
    --
    -- Optional:
    --  negate: negates the check against the value, i.e. whether the value is not equal or not in the list/table.
    args = args or {}
    
    -- Inner type of function depending on whether to check a single value, a list of values or an associative list of values
    local inner
    if args.value ~= nil then
        inner = function (tpl)
            return tpl == args.value
        end
    elseif args.values ~= nil then
        inner = function (tpl)
            for _, value in ipairs(args.values) do
                if tpl == value then
                    return true
                end
            end
            return false
        end
    elseif args.values_assoc ~= nil then
        inner = function(tpl) 
            return args.values_assoc[tpl] ~= nil
        end
    else
        error(string.format('Missing inner comparision function. Args: %s', mw.dumpObject(args)))
    end
    
    -- Outer type of function depending on whether to check a single value or against a table
    return function (tpl_args)
        local tpl_value = tpl_args[args.arg]
        local rtr
        if type(tpl_value) == 'table' then
            rtr = false
            for key, value in pairs(tpl_value) do
                if type(key) == 'number' then
                    rtr = rtr or inner(value)
                else
                    rtr = rtr or inner(key)
                end
            end
        else
            rtr = inner(tpl_value)
        end
        if args.negate then
            rtr = not rtr
        end
        return rtr
     end
end

function h.conditions.factory.not_arg(args)
    args = args or {}
    args.negate = true
    return h.conditions.factory.arg(args)
end

function h.conditions.factory.flag_is_set(args)
    return function (tpl_args)
        return tpl_args._flags[args.flag] == true
    end
end

function h.conditions.factory.acquisition_tag(args)
    return function (tpl_args)
        local negate = args.negate or false
        for _, tag in ipairs(tpl_args.acquisition_tags or {}) do
            if tag == args.tag then
                return not negate
            end
        end
        return negate
    end
end

function h.conditions.factory.drop_monsters(args)
    return function (tpl_args)
        for _, monster in ipairs(tpl_args.drop_monsters or {}) do
            if string.find(monster, args.monster, 1, true) then
                return true
            end
        end
        return false
    end
end

function h.conditions.factory.drop_rarity(args)
    return function (tpl_args)
        for _, rarity in ipairs(tpl_args.drop_rarities_ids or {}) do
            if rarity == args.rarity then
                return true
            end
        end
        return false
    end
end

function h.conditions.factory.drop_level_not_greater_than(args)
    return function (tpl_args)
        if tpl_args.drop_level == nil then
            return true
        end
        return tpl_args.drop_level <= args.level
    end
end

function h.conditions.item_class_has_corrupted_implicits(tpl_args)
    local groups = {
        cfg.class_groups.weapons.keys,
        cfg.class_groups.armor.keys,
        cfg.class_groups.jewellery.keys,
        {['Quiver'] = true, ['Jewel'] = true, ['AbyssJewel'] = true},
    }
    for _, g in ipairs(groups) do
        if h.conditions.factory.arg{arg='class_id', values_assoc=g}(tpl_args) then
            return true
        end
    end
    return false
end

function h.conditions.item_class_has_influences(tpl_args)
    local groups = {
        cfg.class_groups.weapons.keys,
        cfg.class_groups.armor.keys,
        cfg.class_groups.jewellery.keys,
        {['Quiver'] = true},
    }
    for _, g in ipairs(groups) do
        if h.conditions.factory.arg{arg='class_id', values_assoc=g}(tpl_args) and h.conditions.factory.not_arg{arg='class_id', value='FishingRod'}(tpl_args) then
            return true
        end
    end
    return false
end

function h.conditions.item_class_has_synthesised_implicits(tpl_args)
    local groups = {
        cfg.class_groups.weapons.keys,
        cfg.class_groups.armor.keys,
        cfg.class_groups.jewellery.keys,
        {['Quiver'] = true, ['Jewel'] = true, ['AbyssJewel'] = true},
    }
    for _, g in ipairs(groups) do
        if h.conditions.factory.arg{arg='class_id', values_assoc=g}(tpl_args) and h.conditions.factory.not_arg{arg='class_id', value='FishingRod'}(tpl_args) then
            return true
        end
    end
    return false
end

function h.conditions.item_class_has_fractured_modifiers(tpl_args)
    local groups = {
        cfg.class_groups.weapons.keys,
        cfg.class_groups.armor.keys,
        cfg.class_groups.jewellery.keys,
        {['Quiver'] = true, ['Jewel'] = true, ['Map'] = true},
    }
    for _, g in ipairs(groups) do
        if h.conditions.factory.arg{arg='class_id', values_assoc=g}(tpl_args) and h.conditions.factory.not_arg{arg='class_id', value='FishingRod'}(tpl_args) then
            return true
        end
    end
    return false
end

-- ----------------------------------------------------------------------------
-- Additional configuration
-- ----------------------------------------------------------------------------

local c = {}

c.named_conditions = {
    is_normal = h.conditions.factory.arg{arg='rarity_id', value='normal'},
    is_unique = h.conditions.factory.arg{arg='rarity_id', value='unique'},
    is_not_drop_restricted = h.conditions.factory.arg{arg='is_drop_restricted', value=false},
    is_not_corrupted = h.conditions.factory.arg{arg='is_corrupted', value=false},
    is_not_replica = h.conditions.factory.arg{arg='is_replica', value=false},
    drop_level_ngt_divcard_default_max_ilvl = h.conditions.factory.drop_level_not_greater_than{level=cfg.divination_card_exchange_default_max_ilvl},
    item_class_has_corrupted_implicits = h.conditions.item_class_has_corrupted_implicits,
    item_class_has_influences = h.conditions.item_class_has_influences,
    item_class_has_synthesised_implicits = h.conditions.item_class_has_synthesised_implicits,
    item_class_has_fractured_modifiers = h.conditions.item_class_has_fractured_modifiers,
}
-- Order matters!
-- Put most specific outcome at the top and the least specific at the bottom.
c.automatic_recipes = {
--[[
    {
        conditions = {
            function (tpl_args) end,
        },
        text = '',
        parts = {
            {
                name = '',
                item_id = '',
                amount = 0,
                notes = '',
            },
        },
    },
]]
    
}

-- ----------------------------------------------------------------------------
-- Exported functions
-- ----------------------------------------------------------------------------

local p = {}

function p.process_recipes(tpl_args)
    local query_data = {
        id = {},
        name = {},
        page = {},
    }
    local recipes = {}
    
    -- ------------------------------------------------------------------------
    -- Manual data
    -- ------------------------------------------------------------------------
    local recipe_num = #recipes + 1
    local recipe
    repeat
        local prefix = string.format('recipe%s_', recipe_num)
        local part_num = 1
        local part
        recipe = {
            parts = {},
            result_amount = tonumber(tpl_args[prefix .. 'result_amount']) or 1,
            text = m_util.cast.text(tpl_args[prefix .. 'description']),
            automatic = false,
        }
        repeat 
            local part_prefix = string.format('%spart%s_', prefix, part_num)
            part = {
                item_name = tpl_args[part_prefix .. 'item_name'],
                item_id = tpl_args[part_prefix .. 'item_id'], 
                item_page = tpl_args[part_prefix .. 'item_page'], 
                amount = tonumber(tpl_args[part_prefix .. 'amount']),
                notes = m_util.cast.text(tpl_args[part_prefix .. 'notes']),
            }
            
            if part.item_name ~= nil or part.item_id ~= nil or part.item_page ~= nil then
                if part.amount == nil then
                    error(string.format(i18n.errors.missing_amount, part_prefix .. 'amount'))
                else
                    for key, array in pairs(query_data) do
                        local value = part['item_' .. key]
                        if value then
                            if array[value] then
                                table.insert(array[value], {recipe_num, part_num})
                            else
                                array[value] = {{recipe_num, part_num}, }
                            end
                        end
                    end
                    recipe.parts[#recipe.parts+1] = part
                end
            end
            
            part_num = part_num + 1
        until part.item_name == nil and part.item_id == nil and part.item_page == nil
        
        -- recipe was empty, can terminate safely
        if #recipe.parts == 0 then
            recipe = nil
        else
            recipe_num = recipe_num + 1
            recipes[#recipes+1] = recipe
        end
    until recipe == nil

    -- ------------------------------------------------------------------------
    -- Automatic
    -- ------------------------------------------------------------------------
    
    --
    --  maps
    --
    local automatic_index = #recipes + 1
    -- TODO: 3.9.0 Unsure how this works yet, so disabled for now
    --[[if tpl_args.atlas_connections and tpl_args.rarity_id == 'normal' then
        local results = m_cargo.query(
            {'items', 'maps'},
            {'items._pageName',  'items.name'},
            {
                join='items._pageID=maps._pageID',
                where=string.format('items.class_id = "Map" AND items.rarity_id = "normal" AND maps.tier < %s AND items._pageName IN ("%s")', tpl_args.map_tier, table.concat(tpl_args.atlas_connections, '", "')),
            }
        )
        for _, row in ipairs(results) do
            recipes[#recipes+1] = {
                text = i18n.misc.upgraded_from_map,
                result_amount = 1,
                parts = {
                    {
                        item_name = row['items.name'],
                        item_page = row['items._pageName'],
                        amount = 3,
                        notes = nil,
                    },
                },
                automatic = true,
            }
        end
    end]]
    
    --
    -- oils
    --
    if tpl_args._flags.is_blight_item and tpl_args.blight_item_tier > 1 then
        local results = m_cargo.query(
            {'items', 'blight_items'},
            {'items._pageName',  'items.name'},
            {
                join='items._pageID=blight_items._pageID',
                where=string.format('blight_items.tier = %s', tpl_args.blight_item_tier - 1),
            }
        )
        for _, row in ipairs(results) do
            recipes[#recipes+1] = {
                text = nil,
                result_amount = 1,
                parts = {
                    {
                        item_name = row['items.name'],
                        item_page = row['items._pageName'],
                        amount = 3,
                        notes = nil,
                    },
                },
                automatic = true,
            }
        end
    end
    
    
    --
    -- essences
    --
    
    -- exclude remnant of corruption via type
    if tpl_args._flags.is_essence and tpl_args.essence_type > 0 then 
        local results = m_cargo.query(
            {'items', 'essences'},
            {
                'items._pageName',  
                'items.name', 
                'items.metadata_id',
                'essences.category',
                'essences.type',
            },
            {
                join='items._pageID=essences._pageID',
                where=string.format([[
                        (essences.category="%s" AND essences.level = %s)
                        OR (essences.type = %s AND essences.level = %s)
                        OR items.metadata_id = 'Metadata/Items/Currency/CurrencyCorruptMonolith'
                        OR (%s = 6 AND essences.type = 5 AND essences.level >= 5) 
                    ]], 
                    tpl_args.essence_category, tpl_args.essence_level - 1, 
                    tpl_args.essence_type - 1, tpl_args.essence_level,
                    -- special case for corruption only essences
                    tpl_args.essence_type
                ),
                orderBy='essences.level ASC, essences.type ASC',
            }
        )
        
        local remnant = results[1]
        if remnant['items.metadata_id'] ~= 'Metadata/Items/Currency/CurrencyCorruptMonolith' then
            error(string.format('Something went seriously wrong here. Got results: %s', mw.dumpObject(results)))
        end
        for i=2, #results do
            local row = results[i]
            if row['essences.category'] == tpl_args.essence_category then
                -- 3 to 1 recipe
                recipes[#recipes+1] = {
                    automatic = true,
                    result_amount = 1,
                    text = nil,
                    parts = {
                        {
                            item_id = row['items.metadata_id'],
                            item_page = row['items._pageName'],
                            item_name = row['items.name'],
                            amount = 3,
                        },
                    },
                }
                -- corruption +1
                recipes[#recipes+1] = {
                    automatic = true,
                    result_amount = 1,
                    text = i18n.essence_plus_one_level,
                    parts = {
                        {
                            item_id = row['items.metadata_id'],
                            item_page = row['items._pageName'],
                            item_name = row['items.name'],
                            amount = 1,
                        },
                        {
                            item_id = remnant['items.metadata_id'],
                            item_page = remnant['items._pageName'],
                            item_name = remnant['items.name'],
                            amount = 1,
                        },
                    },
                }
            elseif tonumber(row['essences.type']) == tpl_args.essence_type - 1 then
                -- corruption type change
                recipes[#recipes+1] = {
                    automatic = true,
                    result_amount = 1,
                    text = i18n.essence_type_change,
                    parts = {
                        {
                            item_id = row['items.metadata_id'],
                            item_page = row['items._pageName'],
                            item_name = row['items.name'],
                            amount = 1,
                        },
                        {
                            item_id = remnant['items.metadata_id'],
                            item_page = remnant['items._pageName'],
                            item_name = remnant['items.name'],
                            amount = 1,
                        },
                    },
                }
            end
        end
    end
    
    -- data based on mapping
    if tpl_args.drop_enabled and not tpl_args.disable_automatic_recipes then
        -- Test and cache results of all named conditions
        for k, condition in pairs(c.named_conditions) do
            if type(condition) == 'function' then
                c.named_conditions[k] = condition(tpl_args)
            end
        end

        for _, data in ipairs(c.automatic_recipes) do
            local valid = true -- Can this recipe produce the item?

            -- Check cached results for named conditions
            for k, condition in pairs(c.named_conditions) do
                if data.conditions[k] then
                    valid = condition
                    if not valid then
                        break
                    end
                end
            end

            -- Test anonymous conditions
            for _, condition in ipairs(data.conditions) do
                valid = condition(tpl_args) and valid
                if not valid then
                    break
                end
            end

            if valid then
                recipes[#recipes+1] = {
                    automatic = true,
                    result_amount = 1,
                    text = data.text(),
                    parts = data.parts,
                }
                for part_num, row in ipairs(data.parts) do
                    if query_data['id'][row.item_id] then
                        table.insert(query_data['id'][row.item_id], {#recipes, part_num})
                    else
                        query_data['id'][row.item_id] = {{#recipes, part_num}, }
                    end
                end
            end
        end
    end
    
    if #recipes == 0 then
        return
    end
    --
    -- Fetch item data in a single query to sacrifice database load with a lot of references
    --
    local query_data_array = {
        id = {},
        name = {},
        page = {},
    }
    local query_fields = {
        id = 'items.metadata_id',
        page = 'items._pageName',
        name = 'items.name',
    }
    local where = {}
    local expected_count = 0
    for key, thing_array in pairs(query_data) do
        for thing, _ in pairs(thing_array) do
            table.insert(query_data_array[key], thing)
        end
        if #query_data_array[key] > 0 then
            expected_count = expected_count + #query_data_array[key]
            local q_data = table.concat(query_data_array[key], '", "')
            table.insert(where, string.format('%s IN ("%s")', query_fields[key], q_data))
        end
    end
    local results = m_cargo.query(
        {'items'},
        {'items._pageName',  'items.name', 'items.metadata_id'},
        {
            where=table.concat(where, ' OR '),
        }
    )

    -- Now do The Void
    for _, row in ipairs(results) do
        if row[query_fields.id] and string.find(row[query_fields.id], 'Metadata/Items/DivinationCards/', 1, true) then
            local part = {
                item_id = 'Metadata/Items/DivinationCards/DivinationCardTheVoid',
                amount = 1,
            }
            local result = m_cargo.query(
                {'items'},
                {'items._pageName',  'items.name', 'items.metadata_id'},
                {
                    where=string.format('%s = "%s"', query_fields.id, part.item_id),
                }
            )
            if #result > 0 then
                recipes[#recipes+1] = {
                    automatic = true,
                    result_amount = 1,
                    text = i18n.the_void,
                    parts = {part},
                }
                if query_data['id'][part.item_id] then
                    table.insert(query_data['id'][part.item_id], {#recipes, 1})
                else
                    query_data['id'][part.item_id] = {{#recipes, 1}, }
                end
                table.insert(results, result[1])
            end
            break
        end
    end

    for _, row in ipairs(results) do
        for key, thing_array in pairs(query_data) do
            local recipe_parts = thing_array[row[query_fields[key]]]
            if recipe_parts then
                for _, recipe_part in ipairs(recipe_parts) do
                    local entry = recipes[recipe_part[1]].parts[recipe_part[2]]
                    for entry_key, data_key in pairs(query_fields) do
                        -- metadata_id may be nil, since we don't know them for unique items
                        if row[data_key] then
                            entry['item_' .. entry_key] = row[data_key]
                        end
                    end
                end
                -- set this to nil for error checking in later step
                thing_array[row[query_fields[key]]] = nil
            end
        end
    end
    
    -- sbow the broken references if needed
    if #results ~= expected_count then
        -- query data was pruned of existing keys earlier, so only broken keys remain
        for key, array in pairs(query_data) do
            for thing, recipe_parts in pairs(array) do
                for _, recipe_part in ipairs(recipe_parts) do
                    tpl_args._flags.invalid_recipe_parts = true
                    tpl_args._errors[#tpl_args._errors+1] = m_util.string.format(i18n.errors.invalid_recipe_parts, string.format('recipe%s_part%s_item_%s', recipe_part[1], recipe_part[2], key), thing)
                end
            end
        end
    end
    
    --
    -- Check for duplicates
    --
    local delete_recipes = {}
    for i=automatic_index, #recipes do
        for j=1, automatic_index-1 do
            if #recipes[i].parts == #recipes[j].parts then
                local match = true
                for row_id, row in ipairs(recipes[i].parts) do
                    -- Only the fields from the database query are matched since we can be sure they're correct. Other fields may be subject to user error.
                    for _, key in ipairs({'item_id', 'item_name', 'item_page'})  do
                        match = match and (row[key] == recipes[j].parts[row_id][key])
                    end
                end
                if match then
                    tpl_args._flags.duplicate_recipes = true
                    tpl_args._errors[#tpl_args._errors+1] = string.format(i18n.errors.duplicate_recipes, j)
                    delete_recipes[#delete_recipes+1] = j 
                end
            end
        end
    end
    
    for offset, index in ipairs(delete_recipes) do
        table.remove(recipes, index-(offset-1))
    end

    --
    -- Set data
    -- 
    tpl_args.recipes = recipes
    
    -- Set recipes data
    for i, recipe in ipairs(recipes) do
        table.insert(tpl_args._store_data, {
            _table = 'acquisition_recipes',
            recipe_id = i,
            result_amount = recipe.result_amount,
            description = recipe.text,
            automatic = recipe.automatic,
        })
        for j, part in ipairs(recipe.parts) do
            table.insert(tpl_args._store_data, {
                _table = 'acquisition_recipe_parts',
                part_id = j,
                recipe_id = i,
                item_name = part.item_name,
                item_id = part.item_id,
                item_page = part.item_page,
                amount = part.amount,
                notes = part.notes,
            })
        end
    end
end

--
-- Debugging
--

function p.debug_validate_auto_upgraded_from()
    local q = {}
    local chk = {}
    for _, data in ipairs(c.automatic_recipes) do
        for _, part in ipairs(data.parts) do
            q[#q+1] = part.item_id
            chk[part.item_id] = {
                amount=part.amount,
                text=data.text(),
            }
        end
    end
    
    local results = m_cargo.array_query{
        tables={'items', 'stackables'},
        fields={'items.name', 'items.class_id', 'items.description', 'stackables.stack_size'},
        id_field='items.metadata_id',
        id_array=q,
        query={
            join='items._pageName=stackables._pageName',
        },
    }
    
    for _, row in ipairs(results) do
        if row['items.class_id'] == 'DivinationCard' and chk[row['items.metadata_id']].amount ~= tonumber(row['stackables.stack_size']) then
            mw.logObject(string.format('Amount mismatch %s, expected %s', row['items.metadata_id'], row['stackables.stack_size']))
        end
    end
    
    local tbl = mw.html.create('table')
    tbl:attr('class', 'wikitable sortable')
    for _, row in ipairs(results) do
        tbl
            :tag('tr')
                :tag('td')
                    :wikitext(row['items.name'])
                    :done()
                :tag('td')
                    :wikitext(chk[row['items.metadata_id']].text)
                    :done()
                :tag('td')
                    :wikitext(row['items.description'])
                    :done()
                :done()
    end
    
    return tostring(tbl)
end

return p