Module:Item

From Funky Station
Revision as of 11:22, 15 September 2024 by imported>Aliser (added a function for ID lookups by name overrides, which will provide proper errors on fails (instead of direct lookups which fail silently, causing a bug somewhere further down the line, requiring an hour and a half of debugging and pulling hair out RAAAAAAA); unified most lookup functions docs)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Documentation for this module may be created at Module:Item/doc

-- Contains utilities for working with in-game items.

-- todo create external tests for schema tables (under /doc)
-- todo make `generate_list_of_all_items_with_icons` also display items with nontrivial image files

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

-- A table mapping item IDs to their names.
-- Keys are item IDs; each value is a string.
--
-- These names are used for labels.
-- This table will be updated automatically, DO NOT make manual changes to it - they will be lost.
local item_names_by_item_ids = mw.loadJsonData("Module:Item/item names by item ids.json")

-- A table mapping item names to their IDs.
-- Keys are item names; each value is an item ID.
-- An item can have multiple names.
--
-- These names are used for ID lookups.
-- This table will be updated automatically, DO NOT make manual changes to it - they will be lost.
local item_ids_by_item_names = mw.loadJsonData("Module:Item/item ids by item lowercase names.json")

-- Same as `item_ids_by_item_names`, but has a higher priority
-- and meant to be filled manually.
--
-- These names are used for ID lookups.
local item_ids_by_item_names_override = mw.loadJsonData("Module:Item/item ids by item lowercase names overrides.json")

-- A table mapping item IDs to their image files.
-- Keys are item IDs; each value is a file name or an object (for items with multiple textures).
--
-- Meant to be filled manually.
--
-- These are used to display item icons.
local item_images_by_item_ids = mw.loadJsonData("Module:Item/item image files by item id.json")

-- A table mapping item IDs to specific pages.
-- Keys are item IDs; each value is a page name.
--
-- Meant to be filled manually.
--
-- These are used to turn items into links.
local item_page_links_by_item_ids = mw.loadJsonData("Module:Item/item page links by item ids.json")

-- Get a reference to the current frame.
local current_frame = mw:getCurrentFrame()

-- A boolean that becomes `true` once the template styles for {{Item}} has been added to the page.
-- Used to not add them a million times for all items generations.
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

-- Makes the first letter uppercase.
-- Source: https://stackoverflow.com/a/2421746
local function capitalize(str)
    return (str:gsub("^%l", string.upper))
end

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

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

-- A table of item IDs that were validated for `validate_item_images_by_item_ids_table_entry`..
local validate_item_images_by_item_ids_table_entry__validated_item_ids = {}

-- Validator for item images table entries.
--
-- Used internally for lazilly validating schema of entries.
--
-- Once a validation is conducted for an entry, subsequent calls for the same item ID will
-- not trigger revalidation, thus the lazy part.
local function validate_item_images_by_item_ids_table_entry(entry, item_id)
    -- skip validation for already validated entries
    if validate_item_images_by_item_ids_table_entry__validated_item_ids[item_id] ~= nil then
        return
    end

    assert_value_not_nil(entry, "item images json file validation failed: no entry was found with item ID: " .. item_id)

    if type(entry) == 'table' then
        assert_value_not_nil(entry.default,
            "item images json file validation failed: expected 'default' to be defined for item: " .. item_id)
        assert(type(entry.default) == "string",
            "item images json file validation failed: expected 'default' to be a string, found '" ..
            type(entry.default) .. "' for item: " .. item_id)

        local byCondition = entry.byCondition
        assert_value_not_nil(entry.byCondition,
            "item images json file validation failed: expected 'byCondition' to be defined since 'amount' was given; item: " ..
            item_id)
        assert(type(entry.byCondition) == "table",
            "item images json file validation failed: expected 'byCondition' to be a table, found '" ..
            type(entry.byCondition) .. "' for item: " .. item_id)

        for _, byConditionEntry in ipairs(byCondition) do
            local entry_type = byConditionEntry.type
            assert_value_not_nil(entry_type,
                "item images json file validation failed: expected 'type' to be defined on one of 'byCondition' entries; item: " ..
                item_id)
            assert(type(entry_type) == "string",
                "item images json file validation failed: expected 'type' to be a string on one of 'byCondition' entries, found '" ..
                type(entry_type) .. "' for item: " .. item_id)

            if entry_type == 'amount' then
                local conditions = byConditionEntry.conditions
                assert_value_not_nil(conditions,
                    "item images json file validation failed: expected 'conditions' to be defined on one of 'byCondition' entries; item: " ..
                    item_id)
                assert(type(conditions) == "table",
                    "item images json file validation failed: expected 'conditions' to be a table on one of 'byCondition' entries, found '" ..
                    type(conditions) .. "' for item: " .. item_id)

                for _, condition in ipairs(conditions) do
                    local file = condition.file
                    assert_value_not_nil(file,
                        "item images json file validation failed: expected 'file' in one of 'conditions' entries in on one of 'byCondition' entries to be defined for item: " ..
                        item_id)

                    local conditionMin = condition.min
                    if conditionMin ~= nil then
                        assert(type(conditionMin) == "number",
                            "item images json file validation failed: expected 'min' in one of 'conditions' entries in on one of 'byCondition' entries to be a number, found '" ..
                            type(condition.min) .. "' for item: " .. item_id)
                    end
                end
            else
                error(
                    "item images json file validation failed: expected 'type' to be one of known values on one of 'byCondition' entries, but found '" ..
                    entry_type .. "' entries; item: " .. item_id)
            end
        end
    end

    validate_item_images_by_item_ids_table_entry__validated_item_ids[item_id] = true
end

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

-- Lookups item ID by item name override (any casing).
--
-- Raises an error if no item name override was found.
-- Set `no_error` to `true` to return `nil` instead.
function p.lookup_item_id_by_name_override(item_name_override, no_error)
    assert_value_not_nil(item_name_override,
        "item ID lookup by item name override failed: item name override was not provided")

    local item_name_override_lower = string.lower(item_name_override)

    local item_id = item_ids_by_item_names_override[item_name_override_lower]
    if item_id == nil then
        if no_error then
            return nil
        else
            error("item ID lookup by item name override failed: no item name override '" ..
                item_name_override_lower ..
                "' was found. Make sure that an override is defined in the item name overrides table of Module:Item")
        end
    end

    if not p.item_exists_with_id(item_id) then
        error("item ID lookup by item name override failed: item with looked up item ID '" ..
            item_id .. "' does not exist (item name override: '" ..
            item_name_override_lower ..
            "'). Make sure that the name override for this item is defined correctly in Module:Item and the item exist")
    end

    return item_id
end

-- Lookups item ID by item name `item_name` (any casing).
--
-- Raises an error if no item was found.
-- Set `no_error` to `true` to return `nil` instead.
function p.lookup_item_id_by_item_name(item_name, no_error)
    assert_value_not_nil(item_name, "item ID lookup by item name failed: item name was not provided")

    -- first, try to lookup item name in name overrides
    return p.lookup_item_id_by_name_override(item_name, true)
        -- then, look in regular item names
        or item_ids_by_item_names[string.lower(item_name)]
        or passthrough_assert_true(
            no_error,
            nil,
            "item ID lookup by item name failed: no item name was found by item ID '" ..
            item_name .. "'. Make sure that an item exist with this ID or a name override is defined"
        )
end

-- Lookups item ID by query.
-- Query can either be an item ID (strict casing) or item name (any casing).
--
-- Raises an error if no item was found.
-- Set `no_error` to `true` to return `nil` instead.
function p.lookup_item_id(query, no_error)
    assert_value_not_nil(query, "item ID lookup failed: item ID/name (query) was not provided")

    if item_names_by_item_ids[query] ~= nil then
        return query
    else
        return p.lookup_item_id_by_item_name(query, true)
            or passthrough_assert_true(
                no_error,
                nil,
                "item ID lookup failed: no item was found by ID/name '" ..
                query .. "'. Make sure that an item exist with this ID or a name override is defined"
            )
    end
end

-- Lookups item name by item ID `item_id` (strict casing).
--
-- Raises an error if if no item was found.
-- Set `no_error` to `true` to return `nil` instead.
function p.lookup_item_name_by_item_id(item_id, no_error)
    assert_value_not_nil(item_id, "item name lookup by item ID failed: item ID was not provided")

    return item_names_by_item_ids[item_id]
        or
        passthrough_assert_true(
            no_error,
            nil,
            "item name lookup by item ID failed: no item name was found by item ID '" ..
            item_id .. "'. Make sure that an item exist with this ID or a name override is defined"
        )
end

-- Lookups item name by query.
-- Query can either be an item ID (strict casing) or item name (any casing).
--
-- Raises an error if if no item was found.
-- Set `no_error` to `true` to return `nil` instead.
function p.lookup_item_name(query, no_error)
    assert_value_not_nil(query, "item name lookup failed: item ID/name (query) was not provided")

    local query_lower = string.lower(query)

    if p.lookup_item_id_by_name_override(query, true) ~= nil or item_ids_by_item_names[query_lower] ~= nil then
        return query
    else
        return p.lookup_item_name_by_item_id(query, true)
            or passthrough_assert_true(
                no_error,
                nil,
                "item name lookup failed: no item was found by ID/name '" ..
                query .. "'. Make sure that an item exist with this ID or a name override is defined"
            )
    end
end

-- Checks whether an item exists with name `item_name` (any casing).
function p.item_exists_with_name(item_name)
    -- query non-nil assertion is done in the subsequent function call

    return p.lookup_item_id_by_item_name(item_name, true) ~= nil
end

-- Checks whether an item exists with ID `item_id` (strict casing).
function p.item_exists_with_id(item_id)
    -- query non-nil assertion is done in the subsequent function call

    return p.lookup_item_name_by_item_id(item_id, true) ~= nil
end

-- Checks whether an item exists by query.
-- Query can either be an item ID (strict casing) or item name (any casing).
function p.item_exists(query)
    -- query non-nil assertion is done in the subsequent function calls

    return p.item_exists_with_id(query)
        or p.item_exists_with_name(query)
end

-- Lookups item image by query.
-- Query can either be an item ID (strict casing) or item name (any casing).
--
-- An `amount` can be specified to correctly pick an image for items
-- with multiple images (depending on the amount). By default, it has no value.
--
-- Raises an error if no item was found by `query`.
--
-- Returns `nil` if no image is defined for an item.
function p.try_lookup_item_image(query, amount)
    local item_id = p.lookup_item_id(query, true)
    assert_value_not_nil(item_id, "item image lookup failed: no item was found by ID/name '" .. query .. "'")

    local item_image = item_images_by_item_ids[item_id]
    if item_image == nil then
        return nil
    elseif type(item_image) == 'string' then
        return item_image
    end

    -- if item image "entry" was found and it's not a string,
    -- then it must be a config for multiple images.
    --
    -- send the "entry" to validation.
    validate_item_images_by_item_ids_table_entry(item_image, item_id)

    -- if validation was successful with no errors,
    -- now we can utilize the config schema without doing any checks.

    -- if no amount is specified,
    -- then there's no reason to resolve further.
    -- use the default image file for that config.
    if amount == nil then
        return item_image.default
    end

    -- if amount is specified, then resolve further
    for _, byConditionEntry in ipairs(item_image.byCondition) do
        local entry_type = byConditionEntry.type

        if entry_type == 'amount' then
            local conditions = byConditionEntry.conditions

            for _, condition in ipairs(conditions) do
                -- currently, there's a single condition - "min".
                -- it might be unset, meaning no other conditions,
                local conditionMin = condition.min
                if conditionMin == nil then
                    -- if this condition is not set, then there's no other conditions to check.
                    -- use the file from this condition
                    return condition.file
                elseif amount >= conditionMin then
                    -- if condition is set - validate it.
                    -- if it satisfies - return the file.
                    return condition.file
                end
            end
        else
            error(
                "item image lookup failed: unknown entry type '" .. entry_type .. "' for item '" .. item_id .. "'")
        end
    end

    -- if not a single condition satisfied - raise an error
    error("item image lookup failed: no condition satisfied for item '" .. item_id .. "'")
end

-- Lookups item page name by query.
-- Query can either be an item ID (strict casing) or item name (any casing).
--
-- Returns `nil` if no page is defined for an item.
function p.try_lookup_item_page(query)
    local item_id = p.lookup_item_id(query, true)
    assert_value_not_nil(item_id, "item page lookup failed: no item was found by ID/name '" .. query .. "'")

    return item_page_links_by_item_ids[item_id]
end

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

-- Generates an item element.
-- This is the main function of this module.
function p.generate_item(frame)
    local args = getArgs(frame)
    local argsWithWhitespace = getArgs(frame, { trim = false, removeBlanks = false })

    -- [REQUIRED]

    -- input item name or ID.
    -- any casing is allowed for name, but ID must follow strict casing.
    local input_item = args[1]
    assert_value_not_nil(input_item, "failed to generate an item: item was not provided")

    -- [OPTIONAL]

    -- amount of item.
    -- input is a string number or nil
    local input_amount = tonumber(args[2])

    -- item icon size. uses CSS units.
    local input_icon_size = args.size or "32px"

    -- text label. can be set, otherwise inferred from the item later (so "nil" for now).
    local input_label = argsWithWhitespace.label or argsWithWhitespace.l

    -- whether to capitalize the label. false by default.
    local input_capitalize_label = yesNo(args.capitalize or args.cap or false)

    -- a link to a page.
    -- if set, turns item into a link.
    -- if unset, and item has a link defined for it in the config - uses it.
    local input_link = args.link

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

    local item_id = p.lookup_item_id(input_item, true)
    assert_value_not_nil(item_id, "item generation failed: no item was found by ID/name '" .. input_item .. "'")

    local item_image_filename = p.try_lookup_item_image(item_id, input_amount)

    local item_page_link = input_link
        or p.try_lookup_item_page(item_id)


    local label
    if input_label == nil then
        -- if a custom label is not provided, lookup the item's label
        label = p.lookup_item_name_by_item_id(item_id)
    else
        -- if a label is provided - use it
        label = input_label
    end

    if input_capitalize_label then
        label = capitalize(label)
    end

    if input_amount ~= nil then
        label = input_amount .. " " .. label
    end

    if item_page_link ~= nil then
        label = "[[" .. item_page_link .. "|" .. label .. "]]"
    end

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

    local item_el = mw.html.create("span")
        :addClass("item")

    -- add icon element inside the label if icon is provided
    if item_image_filename ~= nil then
        local link_param = ''
        if item_page_link ~= nil then
            link_param = '|link=' .. item_page_link
        end

        item_el:node("[[File:" ..
            item_image_filename .. "|" .. input_icon_size .. "|class=item-icon" .. link_param .. "]]")
    end

    item_el:node(label)


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

        was_template_styles_tag_el_added = true
    end


    return item_el
        :allDone()
end

function p.generate_list_of_all_items_with_icons(frame)
    local args = getArgs(frame)
    local columns_count = args[1]
    assert_value_not_nil(columns_count, "columns count was not provided")

    local container = mw.html.create("div")
        :css("column-count", columns_count)

    -- an array of item ids that have images
    local item_ids_with_images = {}
    for item_id, _ in pairs(item_images_by_item_ids) do
        table.insert(item_ids_with_images, item_id)
    end

    local function assert_looked_up_item_name_is_not_nil(item_id, item_name)
        assert_value_not_nil(item_name,
            "failed to generate a list of items with icons: no item was found by ID '" ..
            item_id ..
            "'. This likely indicates that the item with this ID was removed or the ID was misspelled in the item image files table")
    end

    -- sort alphabetically
    table.sort(item_ids_with_images, function(first, second)
        local first_item_name = p.lookup_item_name_by_item_id(first, true)
        local second_item_name = p.lookup_item_name_by_item_id(second, true)

        assert_looked_up_item_name_is_not_nil(first, first_item_name)
        assert_looked_up_item_name_is_not_nil(second, second_item_name)

        return first_item_name < second_item_name
    end)

    -- generate child elements from the template
    for _, item_id in ipairs(item_ids_with_images) do
        local item_el = p.generate_item { item_id }
            :css("display", "block")

        container:node(item_el)
    end

    return container
        :allDone()
end

-- -- Generates a list of ALL items.
-- -- Will likely break :clueless:
function p.generate_list_of_all_items(frame)
    local args = getArgs(frame)
    local columns_count = args[1]
    assert_value_not_nil(columns_count, "columns count was not provided")

    local container = mw.html.create("div")
        :css("column-count", columns_count)

    local item_ids = {}
    for item_id, _ in pairs(item_names_by_item_ids) do
        table.insert(item_ids, item_id)
    end

    -- sort alphabetically
    table.sort(item_ids, function(first, second)
        return p.lookup_item_name_by_item_id(first) < p.lookup_item_name_by_item_id(second)
    end)

    -- generate child elements from the template
    for _, item_id in ipairs(item_ids) do
        local item_el = p.generate_item { item_id }
            :css("display", "block")
            :node(" <span style='color: gray;'>ID " .. item_id .. "</span>")

        container:node(item_el)
    end

    return container
        :allDone()
end

return p