Module:Sandbox/LVL: Difference between revisions

Jump to navigation Jump to search
LVL (talk | contribs)
No edit summary
Tag: Undo
LVL (talk | contribs)
No edit summary
Tag: Manual revert
Line 1: Line 1:
local p = {}
local p = {}
local items_data = mw.loadJsonData("Data:ItemData.json")
local lang_module = require('Module:Lang')
local generic_module = require('Module:GenericData')
local util_module = require('Module:Utilities')


-- Helper function to flatten item data into searchable text using existing Lang JSON files
local heroes_data      = mw.loadJsonData("Data:HeroData.json")
local function extract_search_terms(data, lang_code)
local attributes_data  = mw.loadJsonData("Data:AttributeData.json")
    -- Load the language file directly for fast, frame-less lookups
local attribute_orders  = mw.loadJsonData("Data:StatInfoboxOrder.json")
    local lang_file_name = string.format("Data:Lang_%s.json", lang_code or "en")
local util_module      = require('Module:Utilities')
    local success, lang_data = pcall(mw.loadJsonData, lang_file_name)
local lang_module      = require('Module:Lang')
    if not success then lang_data = {} end
local dictionary_module = require('Module:Dictionary')
local attribute_module  = require('Module:AttributeData')
local hero_data_module  = require('Module:HeroData')


    -- Keys to explicitly ignore (metadata that might have translations but we don't want in search)
local function get_nested_stat_value(hero_data, stat_key)
     local ignore_keys = {
     if hero_data[stat_key] ~= nil then
        ["Name"] = true, ["Description"] = true, ["Cost"] = true, ["Tier"] = true,
         return hero_data[stat_key]
         ["Activation"] = true, ["Slot"] = true, ["Components"] = true, ["TargetTypes"] = true,
    elseif hero_data.Weapon and hero_data.Weapon[stat_key] ~= nil then
        ["ShopFilters"] = true, ["IsDisabled"] = true, ["StreetBrawl"] = true, ["IsImbue"] = true,
         return hero_data.Weapon[stat_key]
         ["ChannelMoveSpeed"] = true -- Keep this ignored since every item has it
     elseif hero_data.Weapon and hero_data.Weapon.AltFire and hero_data.Weapon.AltFire[stat_key] ~= nil then
     }
        return hero_data.Weapon.AltFire[stat_key]
 
    end
    local terms = {}
    return 0
    local function recurse(tbl)
end
        if type(tbl) ~= "table" then return end
        for k, v in pairs(tbl) do
            -- Only process string keys that aren't in our ignore list
            if type(k) == "string" and not ignore_keys[k] then
                -- Try to get the in-game name from the lang file
                local term = lang_data[k .. "_label"] or lang_data[k]
               
                -- ONLY add if a valid translation exists in the lang file!
                if term and type(term) == "string" then
                    -- Clean up any HTML tags or inline attributes from the localization string
                    term = term:gsub("<[^>]+>", " ")
                    term = term:gsub("{g:citadel_inline_attribute:'(.-)'}", "%1")
                    table.insert(terms, string.lower(term))
                end
                -- Notice: NO ELSE BLOCK! If it's not in the lang file, we drop it completely.
            end


            if type(v) == "table" then
local function localize(key, fallback)
                recurse(v)
    local result = lang_module.get_string(key)
            end
    if result == "" or result == nil then
        end
        result = util_module.add_space_before_cap(fallback) ..
                mw.getCurrentFrame():expandTemplate{title = "MissingValveTranslationTooltip"}
     end
     end
    recurse(data)
    local result = table.concat(terms, " ")
    result = result:gsub("%s+", " ") -- Normalize spaces
     return result
     return result
end
end


--exceptions if the link is different from the item name
-- {{#invoke:HeroComparisonTable|write_hero_comparison_table|POWER_INCREASES|SPIRIT_POWER|MAX_POWER|MAX_SPIRIT}}
local linkOverrides = {
p.write_hero_comparison_table = function(frame)
     ["Bullet Lifesteal"] = "Bullet Lifesteal (item)",
    local power_increases = tonumber(frame.args[1]) or 0
     ["Spirit Lifesteal"] = "Spirit Lifesteal (item)"
     local spirit_power    = tonumber(frame.args[2]) or 0
}
    local max_power      = tonumber(frame.args[3]) or 25
     local max_spirit      = tonumber(frame.args[4]) or 500
 
    local display_scaling_icons = (power_increases == 0 and spirit_power == 0)
 
    local body_str  = ""
    local hero_name
    local hero_name_en
    local hero_icon
    local template_args = {}


--With debug_mode on, it outputs unprocessed wikitext
    local stats_to_include = {
--With debug_mode off/unspecified, it processes the wikitext
        Weapon = {
local function process_debug_mode(wikitext, debug_mode)
            "DPS",
if debug_mode == 'true' then
            "SustainedDPS",
return wikitext
            "BulletDamage",
elseif debug_mode == 'false' or debug_mode == nil then
            "RoundsPerSecond",
return mw.getCurrentFrame():preprocess(wikitext)
            "FireRate",
else
            "ClipSize",
return "debug_mode must be 'true' or 'false'"
            "ReloadTime",
end
            "ReloadDelay",
end
            "BulletsPerShot",
            "BulletsPerBurst",
            "BurstInterShotInterval",
            "LightMeleeDamage",
            "HeavyMeleeDamage",
            "ReloadSingle",
            "BulletSpeed",
            "BulletGravityScale",
            "FalloffStartRange",
            "FalloffEndRange",
            "BonusAttackRange",
            "CritDamageBonusPercent",
            "RoundsPerSecondAtMaxSpin",
            "SpinAcceleration",
            "SpinDeceleration"
        },
        Vitality = {
            "MaxHealth",
            "BaseHealthRegen",
            "BulletResist",
            "TechResist",
            "MeleeResist",
            "BulletLifesteal",
            "CritDamageReceivedPercent",
            "MaxMoveSpeed",
            "SprintSpeed",
            "StaminaCooldown",
            "Stamina",
            "GroundDashSpeed"
        },
        Spirit = {
            "TechPower"
        }
    }


--Writes list of items of a certain slot within the min and max soul bounds
    -- Collect and sort heroes alphabetically, skipping disabled/in-development
-- Each item is wrapped and separated. For example of wrapping/separator, see
    local sorted_heroes = {}
-- get_item_nav_bulletpoints. 'sep' should not be combined with 'right_wrap',
    for hero_key, hero_data in pairs(heroes_data) do
-- as the trailing separator should also be removed from the string
        if not hero_data["InDevelopment"] and not hero_data["IsDisabled"] then
-- filter_mode parameter: nil/"all", "exclude_street_brawl", or "street_brawl_only"
            table.insert(sorted_heroes, {key = hero_key, data = hero_data})
local function write_wrapped_item_list(slot, min_souls, max_souls, template, sep, filter_mode)
         end
    if slot ~= 'Weapon' and slot ~= 'Armor' and slot ~= 'Tech' then
         return 'slot must be Weapon, Armor (Vitality), or Tech (Spirit)'
     end
     end
      
     table.sort(sorted_heroes, function(a, b)
    local min_souls = tonumber(min_souls)
        local function get_sort_name(name)
    local max_souls = tonumber(max_souls)
            return (name:gsub("^The ", ""))
    if min_souls == nil or max_souls == nil then return 'Min/Max souls must be numerical' end
   
    -- Normalize filter_mode to handle unset parameter
    filter_mode = filter_mode or "all"
   
    -- Retrieve all items that fit the bounds and filter criteria
    local items = {}
    for item_key, item_data in pairs(items_data) do
        -- future proofing; Disabled will be renamed to IsDisabled soon
        local this_cost = tonumber(item_data["Cost"])
        local this_slot = item_data["Slot"]
       
        -- Filter logic for Street Brawl items
        local is_street_brawl = item_data["StreetBrawl"] == true
        local should_include = true
       
        if filter_mode == "street_brawl_only" and not is_street_brawl then
            should_include = false
        elseif filter_mode == "exclude_street_brawl" and is_street_brawl then
            should_include = false
         end
         end
          
         return get_sort_name(a.data["Name"] or a.key) < get_sort_name(b.data["Name"] or b.key)
         if should_include and item_data["Name"] ~= nil and item_data["IsDisabled"] == false and this_cost ~= nil and this_slot ~= nil then
    end)
            if slot == this_slot and this_cost >= min_souls and this_cost < max_souls then
 
                local item_name_english = lang_module.get_string(item_key, "en") -- Get the English name
    -- Build table body rows
                local item_link = linkOverrides[item_name_english] or item_name_english -- Use override link if available
    for _, hero_entry in ipairs(sorted_heroes) do
                local lang_code = lang_module.get_lang_code() -- Get the language code from the subpage
         local hero_key  = hero_entry.key
                local item_name_local = lang_module.get_string(item_key, lang_code) -- Get the localized name
        local hero_data = hero_entry.data
               
        local row_str  = ""
                -- Construct the template based on the type
 
                local item_template
        hero_name    = localize(hero_key, hero_key)
                if template == "ItemIcon" then
        hero_name_en = hero_data["Name"]
                     if lang_code == "en" then
 
                         -- For English pages, do not include lang or localized name
        template_args = {[1] = hero_name_en, l1 = hero_name}
                         item_template = "{{ItemIcon|" .. item_name_english .. "|size=26px}}"
        hero_icon = mw.getCurrentFrame():expandTemplate{ title = "Template:HeroIcon", args = template_args }
 
        row_str = row_str ..
            '<td style="position: sticky; left: 0; z-index: 10; background-color: #202122; isolation: isolate; overflow: hidden;">' ..
            hero_icon .. "</td>"
 
        for _, category in ipairs(attribute_orders["category_order"]) do
            if stats_to_include[category] ~= nil then
                for _, attr_key in ipairs(stats_to_include[category]) do
 
                    local base_value  = get_nested_stat_value(hero_data, attr_key)
                    local stat_value  = base_value
                    local scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key)
                    local scaling_strs = ""
                    local spirit_scale = 0
                    local level_scale  = 0
 
                    if scaling_data ~= nil then
                        for scaling_val, scaling_type in pairs(scaling_data) do
                            local scaling_str = hero_data_module.write_scalar_str(scaling_val, scaling_type, true)
                            if scaling_str ~= "" then scaling_str = " " .. scaling_str end
                            if scaling_str ~= nil then
                                scaling_strs = scaling_strs .. scaling_str
                            end
 
                            if scaling_type == "Spirit" then
                                spirit_scale = scaling_val
                                stat_value  = stat_value + (spirit_power * scaling_val)
                            elseif scaling_type == "Level" then
                                level_scale = scaling_val
                                stat_value  = stat_value + (power_increases * scaling_val)
                            end
                        end
                    end
 
                     if attr_key == "TechPower" and spirit_scale == 0 then
                         spirit_scale = 1.0
                         stat_value  = stat_value + (spirit_power * spirit_scale)
                    end
 
                    local innate_spirit_scale =
                        (hero_data["LevelScaling"] and hero_data["LevelScaling"]["TechPower"]) or 0
 
                    if not display_scaling_icons then scaling_strs = "" end
 
                    if type(stat_value) == "boolean" then
                        stat_value = tostring(stat_value)
                     else
                     else
                         -- For non-English pages, include lang and localized name
                         stat_value = util_module.round_to_sig_fig(stat_value, 3)
                        item_template = "{{ItemIcon|" .. item_name_english .. "|lang=" .. lang_code .. "|l1=" .. item_name_local .. "|size=26px}}"
                     end
                     end
                      
 
                    -- Build data attribute for searching (names + stats + description)
                     local cell_inner = string.format(
                     local search_data = string.lower(item_name_english)
                        '<span class="stat-num">%s</span><span class="stat-scaling">%s</span>',
                     if lang_code ~= "en" and item_name_local then
                        stat_value,
                         search_data = search_data .. " " .. string.lower(item_name_local)
                        scaling_strs
                    )
 
                     local hit_once = "false"
                     if (attr_key == "DPS" or attr_key == "SustainedDPS")
                        and hero_data.Weapon
                        and hero_data.Weapon.HitOnceAcrossAllBullets
                    then
                         hit_once = "true"
                     end
                     end
                      
 
                    -- Append flattened item data (stats only, no descriptions/shopfilters)
                     local data_attrs = string.format(
                    search_data = search_data .. " " .. extract_search_terms(item_data, lang_code)
                        'data-stat-name="%s" data-base="%s" data-spirit-scale="%s" data-level-scale="%s" data-innate-spirit-scale="%s" data-sort-value="%s" data-hit-once="%s"',
                   
                        attr_key,
                    -- Remove double and single quotes so they don't break the HTML attribute
                        tostring(type(base_value) == "number" and util_module.round_to_sig_fig(base_value, 3) or base_value),
                    search_data = search_data:gsub('["\']', '')
                        tonumber(spirit_scale) or 0,
                      
                        (attr_key == "TechPower") and "0" or (tonumber(level_scale) or 0),
                    -- Wrap item and its bullet separator in a searchable span
                        innate_spirit_scale or 0,
                     item_template = '<span class="item-nav-search-wrapper"><span class="item-nav-search-item" data-item-name="' .. search_data .. '">' .. item_template .. '</span><span class="item-nav-search-sep"> &bull; </span></span>'
                        stat_value,
                elseif template == "ItemBox" then
                        hit_once
                    if lang_code == "en" then
                     )
                        -- For English pages, do not include item_loc or link
 
                        -- Display cost as "Legendary" for street brawl items
                     row_str = row_str ..
                        if is_street_brawl == true then
                        string.format('<td style="white-space: nowrap;" %s>%s</td>', data_attrs, cell_inner)
item_template = "{{ItemBox|itemName=" .. item_name_english .. "|overrideLinkClick=" .. item_link .. "|overrideTier=5" .. "}}"
                end
                         else
            end
                             item_template = "{{ItemBox|itemName=" .. item_name_english .. "|overrideLinkClick=" .. item_link .. "}}"
        end
 
        body_str = body_str .. "<tr>" .. row_str .. "</tr>"
    end
 
    -- Pre-pass: determine which stats have any scaling, and which have both types
    local stats_with_any_scaling  = {}
    local stats_with_both_scaling = {}
    for _, hero_entry in ipairs(sorted_heroes) do
        local hero_data = hero_entry.data
        for _, category in ipairs(attribute_orders["category_order"]) do
            if stats_to_include[category] ~= nil then
                for _, attr_key in ipairs(stats_to_include[category]) do
                    local scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key)
                    if scaling_data ~= nil and next(scaling_data) ~= nil then
                        stats_with_any_scaling[attr_key] = true
 
                        local has_spirit = false
                        local has_level  = false
                         for _, scaling_type in pairs(scaling_data) do
                             if scaling_type == "Spirit" then has_spirit = true end
                            if scaling_type == "Level" then has_level  = true end
                         end
                         end
                    else
                         if has_spirit and has_level then
                         -- For non-English pages, include item_loc and link
                             stats_with_both_scaling[attr_key] = true
                        local link_local = item_link .. "/" .. lang_code -- Add subpage for non-English languages
                        if is_street_brawl == true then
                             item_template = "{{ItemBox|itemName=" .. item_name_english .. "|overrideLinkClick=" .. link_local ..
                            "|item_price={{#invoke:Lang|get_string|Citadel_ItemDraft_Legendary}}" .. "}}"
                        else
                        item_template = "{{ItemBox|itemName=" .. item_name_english .. "|overrideLinkClick=" .. link_local .. "}}"
                         end
                         end
                     end
                     end
                else
                    return "Invalid template type"
                 end
                 end
                table.insert(items, item_template)
             end
             end
         end
         end
     end
     end
   
    -- Order list alphabetically
    table.sort(items) -- O(nlogn)
   
    -- Add each item to output
    -- Each item is already wrapped in the template, so no need for additional wrapping
    local ret = table.concat(items, sep)
   
    return ret
end


-- for [[Template:Item Navbox]]
    -- Build header row
-- Supports filter parameter via frame.args["filter"]
    local headers_str = '<th style="position: sticky; left: 0; top: -1px; z-index: 12; background-color: #27292d; isolation: isolate;">Hero</th>'
function p.get_item_nav_bulletpoints(slot, min_souls, max_souls, debug_mode, filter_mode)
    local category_data = attribute_module.get_category_data()
     -- Handle the case where it's called via #invoke (i.e., from wikitext)
     local postfix_key_map = {
    if type(slot) == "table" and slot.args then
        ["ReloadDelay"]              = "StatDesc_ReloadTime_postfix",
         local frame = slot
        ["BulletsPerShot"]          = "",
         slot = frame.args[1]
        ["BulletsPerBurst"]          = "",
         min_souls = frame.args[2]
         ["BurstInterShotInterval"]  = "StatDesc_ReloadTime_postfix",
         max_souls = frame.args[3]
         ["ReloadSingle"]            = "",
         debug_mode = frame.args["debug_mode"]
        ["BonusAttackRange"]         = "StatDesc_WeaponRangeFalloffMax_postfix",
         filter_mode = frame.args["filter"]
         ["SustainedDPS"]            = "DPS_postfix",
     end
        ["RoundsPerSecondAtMaxSpin"] = "",
         ["CritDamageBonusScale"]    = "StatDesc_CritDamageBonusScale_postfix",
     local sep = ''
        ["BonusCritDamagePercent"]   = "BonusCritDamagePercent_postfix",
         ["BulletLifesteal"]         = "BulletLifestealPercentHero_postfix",
    local item_list = write_wrapped_item_list(slot, min_souls, max_souls, "ItemIcon", sep, filter_mode)
         ["GroundDashSpeed"]         = "DashSpeed_postfix"
     }
    return process_debug_mode(item_list, debug_mode)
 
end
     for _, category in ipairs(attribute_orders["category_order"]) do
        local category_attrs = attributes_data[category]
        local category_rgb  = category_data[category]["rgb"]
 
        if stats_to_include[category] ~= nil then
            for _, attr_key in ipairs(stats_to_include[category]) do
                local attr_data = category_attrs[attr_key]
                local attr_localized
                local postfix


-- for [[Template:Infobox ShopItems]]
                if attr_data ~= nil then
-- Supports filter parameter via frame.args["filter"]
                    attr_localized = lang_module.get_string(attr_data["label"])
function p.get_item_nav_cards(slot, min_souls, max_souls, debug_mode, filter_mode)
                    if attr_localized == nil or attr_localized == "" then
    -- Handle the case where it's called via #invoke (i.e., from wikitext)
                        attr_localized = util_module.add_space_before_cap(attr_key)
    if type(slot) == "table" and slot.args then
                    end
        local frame = slot
                    postfix = lang_module.get_string(attr_data["postfix"])
        slot = frame.args[1]
                    if postfix == nil or postfix == "" then
        min_souls = frame.args[2]
                        postfix = ""
        max_souls = frame.args[3]
                    else
        debug_mode = frame.args["debug_mode"]
                        postfix = " (" .. postfix .. ")"
        filter_mode = frame.args["filter"]
                    end
    end
                else
                    attr_localized = dictionary_module.translate(attr_key)
    local sep = ' '
                    postfix = lang_module.get_string(postfix_key_map[attr_key])
                    if postfix == nil then
    local item_list = write_wrapped_item_list(slot, min_souls, max_souls, "ItemBox", sep, filter_mode)
                        return "attr_key " .. attr_key .. " must be added to postfix_key_map"
                    end
    return process_debug_mode(item_list, debug_mode)
                    if postfix ~= "" then
end
                        postfix = " (" .. postfix .. ")"
                    end
                end


-- for [[Template:Item Navbox]] subgroup rows
                local th_style = 'position: sticky; top: -1px; z-index: 3; background-color: rgb(' .. category_rgb .. ');'
-- Writes price tiers and appends Street Brawl section if items exist
                if stats_with_both_scaling[attr_key] then
function p.write_item_slot_subgroup(frame)
                    th_style = th_style .. ' min-width: 130px;'
local slot = frame.args[1]
                elseif stats_with_any_scaling[attr_key] then
local type = frame.args[2]
                    th_style = th_style .. ' min-width: 75px;'
local debug_mode = frame.args['debug_mode']
                end
local street_brawl_label = frame.args['street_brawl_label'] or '{{Legendary|{{#invoke:Lang|get_string|Citadel_ItemDraft_Legendary}}}}'
if slot == nil then return "'slot' parameter is required" end
-- Define base args
local template_title = "Navbox subgroup"
local template_args = {
["groupstyle"] = "background-color:" .. util_module.get_slot_color(slot) .. ";width:10%;min-width:70px;border-radius: 8px 0 0 8px",
["grouppadding"] = "5px",
["listpadding"] = "0 0.25rem",
}
local soul_style = "font-size: 12px; text-shadow: 1px 1px rgba(0, 0, 0, 0.3);"
local prices = generic_module.get_item_price_per_tier()
local group_index = 1
for i, souls in ipairs(prices) do
min_souls = souls
--Skip 0 to i1 as no items cost less than 500
if min_souls ~= 0 then
-- Determine upper bound for soul tier
max_souls = prices[i+1]
if max_souls == nil then  
max_souls = min_souls * 10
end
-- Generate list for this price tier, excluding Street Brawl items
local list
if type=='get_item_nav_cards' then
list = p.get_item_nav_cards(slot, min_souls, max_souls, debug_mode, "exclude_street_brawl")
elseif type=='get_item_nav_bulletpoints' then
list = p.get_item_nav_bulletpoints(slot, min_souls, max_souls, debug_mode, "exclude_street_brawl")
else
return "'type' should be get_item_nav_cards or get_item_nav_bulletpoints"
end
-- Only add the group if there are items in this tier
if list and list ~= "" then
template_args["group" .. group_index] = frame:expandTemplate{title="Souls", args={[1] = min_souls, ["Shadow"] = soul_style}}
template_args["list" .. group_index] = list
group_index = group_index + 1
end
end
end
-- Add Street Brawl section at the bottom of the slot
local sb_min = 0
local sb_max = 999999
local sb_list
if type=='get_item_nav_cards' then
sb_list = p.get_item_nav_cards(slot, sb_min, sb_max, debug_mode, "street_brawl_only")
elseif type=='get_item_nav_bulletpoints' then
sb_list = p.get_item_nav_bulletpoints(slot, sb_min, sb_max, debug_mode, "street_brawl_only")
end
-- Only add Street Brawl group if there are items for this slot
if sb_list and sb_list ~= "" then
-- Style the link to match other tier headers with white text inside the link to prevent visited link color
local sb_style = "color:#98ffde; font-family:'Retail Demo', sans-serif; text-wrap: nowrap; text-shadow: 0px 0px 2px #2c312e, 1.3px 1.3px rgba(0, 0, 0, 0.2);padding:5px;"
local sb_link_text = "[[Street Brawl{{if_lang}}|<span style=\"" .. sb_style .. "\"><b>" .. street_brawl_label .. "</b></span>]]"
template_args["group" .. group_index] = frame:preprocess(sb_link_text)
template_args["list" .. group_index] = sb_list
end
return frame:expandTemplate{title=template_title, args=template_args}
end


-- for [[Template:Active Items]]
                headers_str = headers_str ..
function p.generate_active_items_table(frame)
                    '<th style="' .. th_style .. '">' .. attr_localized .. postfix .. "</th>"
    local active_items = {}
             end
   
    -- 1. Collect active items and their cost
    for item_key, item_data in pairs(items_data) do
        -- Convert cost to a number; will be nil if the JSON value is null or missing
        local cost = tonumber(item_data["Cost"])
       
        if item_data["Name"] and
          item_data["IsDisabled"] == false and
          cost ~= nil and cost > 0 and            -- FILTER: Must have a cost greater than 0
          item_data["Activation"] and
          item_data["Activation"] ~= "Passive" then
           
            table.insert(active_items, {
                name = item_data["Name"],
                key = item_key,
                cost = cost,
                slot = item_data["Slot"] or "Weapon",
                has_active_desc = item_data["ActiveDescription"] ~= nil
             })
         end
         end
     end
     end
      
     headers_str = "<tr>" .. headers_str .. "</tr>"
    -- 2. Sort by name
 
    table.sort(active_items, function(a, b) return a.name < b.name end)
     return string.format(
   
         '<div id="hero-comparison-container" data-max-power="%s" data-max-spirit="%s">' ..
    -- 3. Generate wiki markup
        '<div style="overflow: auto; max-height: 70vh; width: 100%%;">' ..
    local wikitext = [=[{| class="wikitable mw-collapsible sortable" style="width:100%"
        '<table class="wikitable sortable" style="table-layout: auto; width: 100%%;" id="hero-comparison-table">%s%s</table>' ..
|+{{#invoke:Dictionary|translate|Active Items}}
        '</div></div>',
! colspan="2" | {{#invoke:Lang|get_string|Citadel_HeroBuilds_CategoryNameLabel}}
        max_power,
! {{#invoke:Lang|get_string|Citadel_UserFeedback_TypeLabel}}
        max_spirit,
! {{Souls|{{#invoke:Dictionary|translate|Cost}}}}
        headers_str,
! {{#invoke:Lang|get_string|Citadel_Mod_Tooltip_Active}}
        body_str
! {{#invoke:Lang|get_string|AbilityCooldown_label}}
     )
]=]
      
    -- 4. Add rows for each item
    for _, item in ipairs(active_items) do
         -- Determine background color and item type
        local bg_color, item_type
        if item.slot == "Armor" then
            bg_color = "#86C921"
            item_type = "Armor"
        elseif item.slot == "Tech" then
            bg_color = "#DE9CFF"
            item_type = "Tech"
        else
            bg_color = "#FCAC4D"
            item_type = "Weapon"
        end
       
        -- The cost cell uses 'data-sort-value' to provide a clean number for the client-side sorter,
        -- while the cell's visible content is formatted by the {{Souls}} template.
        wikitext = wikitext .. "\n" .. string.format([=[
|-
! style="background-color:%s" | [[File:%s.png|64x64px]]
! [[%s{{If_lang}}|{{#invoke:Lang|get_string|%s}}]]
| style="text-align:center;font-weight:bold" | {{ItemType|%s|{{#invoke:Lang|get_string|CitadelCategory%s}}}}
| data-sort-value="%d" style="text-align:center;font-size:15px" | {{Souls|%d}}
| {{#invoke:Utilities|process_variables|%s_active_desc|%s}}{{#invoke:Utilities|process_variables|%s_desc|%s}}{{#invoke:Utilities|process_variables|%s_active|%s}}
| style="text-align:center;font-size:16px" | {{#invoke:ItemData|get_prop|%s|AbilityCooldown}}s
]=],
            bg_color,
            item.name,
            item.name, item.key,
            item_type, item_type,
            item.cost, item.cost,
            item.key, item.name, item.key, item.name, item.key, item.name,
            item.name)
     end
   
    wikitext = wikitext .. "\n|}"
   
    return frame:preprocess(wikitext)
end
end


return p
return p