Module:Sandbox/LVL: Difference between revisions

LVL (talk | contribs)
No edit summary
LVL (talk | contribs)
No edit summary
 
(151 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- ====================================================================
-- Dependencies and Initial Setup
-- ====================================================================
local lang = require "Module:Lang"
local commonutils = require "Module:Utilities"
local utils = require "Module:Abilities/utils"
local p = {}
local p = {}
local data = mw.loadJsonData("Data:AbilityCards.json")


-- (ATTR_TYPE_ICON_MAP and helper functions remain the same)
local heroes_data      = mw.loadJsonData("Data:HeroData.json")
local ATTR_TYPE_ICON_MAP = {
local attributes_data  = mw.loadJsonData("Data:AttributeData.json")
bullet_armor_up = {img='Bullet_Armor.png', link='Damage_Resistance', color = 'NoColor'},
local attribute_orders  = mw.loadJsonData("Data:StatInfoboxOrder.json")
bullet_armor_down = {img='Bullet_Armor.png', link='Damage_Resistance', color = 'NoColor'},
local util_module      = require('Module:Utilities')
cast = {img='AttributeIconMaxChargesIncrease.png', link=''},
local lang_module      = require('Module:Lang')
charges = {img='AttributeIconMaxChargesIncrease.png', link='', color = 'Purple'},
local dictionary_module = require('Module:Dictionary')
damage = {img='Damage_heart.png', link='', color = 'NoColor'},
local attribute_module  = require('Module:AttributeData')
bullet_damage = {img='Bullet_damage.png', link='Bullet_Damage', color = 'NoColor'},
local hero_data_module  = require('Module:HeroData')
fire_rate = {img='Fire Rate.png', link='Fire_Rate', color = 'Brown'},
healing = {img='Healing.png', link='Healing', color = 'Green'},
health = {img='Health.png', link='Health', color = 'Green'},
move_speed = {img='Move speed.png', link='Move Speed', color = 'Green'},
range = {img='CastRange.png', link='Ability_Range', color = 'Purple'},
tech_armor_up = {img='Spirit_Armor.png', link='Damage_Resistance', color = 'NoColor'},
tech_damage = {img='AttributeIconTechShieldHealth.png', link='Spirit_Damage', color = 'NoColor',size = '12px'},
distance = {img='AttributeIconTechRange.png', link='Ability_Range', color = 'Purple'},
duration = {img='AttributeIconTechDuration.png', link='Ability Duration', color = 'Purple'},
slow = {img='MoveSlow.png', link='', color = 'Purple'},
melee_damage = {img='Melee damage.png', link='Melee Damage'},
cooldown = {img='Cooldown Icon.png', link='Ability Cooldown', color = 'Purple'},
combat_barrier = {img='Barrier.png', link='Barrier', color = 'Green'},
time = {img='AttributeIconTechDuration.png', link='Ability Duration', color = 'Purple'},
}


function get_hero_key(hero_name) -- Unchanged
local function get_nested_stat_value(hero_data, stat_key)
for i, hero in pairs(data) do
    if hero_data[stat_key] ~= nil then
if hero["Name"] == hero_name then return i end
        return hero_data[stat_key]
end
    elseif hero_data.Weapon and hero_data.Weapon[stat_key] ~= nil then
return nil
        return hero_data.Weapon[stat_key]
    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
    return 0
end
end


function get_icon(attr_type) -- Unchanged
local function localize(key, fallback)
local mappedAttr = ATTR_TYPE_ICON_MAP[attr_type]
    local result = lang_module.get_string(key)
local img = 'GenericProperty.png'
    if result == "" or result == nil then
local link = ''
        result = util_module.add_space_before_cap(fallback) ..
local size = ''
                mw.getCurrentFrame():expandTemplate{title = "MissingValveTranslationTooltip"}
local color = 'Grey'
    end
if mappedAttr then
    return result
img = mappedAttr.img
link = mappedAttr.link
size = mappedAttr.size
color = mappedAttr.color or color
end
return {img=img, link=link, color=color, size=size}
end
end


function get_attr_ss(attr) -- Unchanged
-- =========================================================================
if not attr then return nil end
--  DPS calculation helpers
local scale = attr.Scale
-- =========================================================================
if not scale or scale.Type ~= 'spirit' or scale.Value == 0 then return nil end
 
return commonutils.round_to_sig_fig(scale.Value, 3)
local function calculate_dps(stats, dps_type)
    local rps = stats.RoundsPerSecond or 0
    if rps == 0 then return 0 end
 
    local bullets_per_shot = (stats.HitOnceAcrossAllBullets and 1) or (stats.BulletsPerShot or 1)
    local burst_count = stats.BulletsPerBurst or 1
    local cycle_time = 1 / rps
    local total_cycle_time = cycle_time * burst_count
 
    if total_cycle_time == 0 then return 0 end
 
    local base_damage = stats.BulletDamage or 0
    if dps_type == 'burst' then
        return base_damage * bullets_per_shot * burst_count / total_cycle_time
    end
 
    local clip_size = stats.ClipSize or 0
    if clip_size <= 0 then
        return base_damage * bullets_per_shot * burst_count / total_cycle_time
    end
 
    local reload_time
    if stats.ReloadSingle then
        reload_time = (stats.ReloadTime or 0) * clip_size
    else
        reload_time = stats.ReloadTime or 0
    end
    reload_time = reload_time + (stats.ReloadDelay or 0)
 
    local time_to_empty_clip = (clip_size / burst_count) * total_cycle_time
    local damage_from_clip = base_damage * bullets_per_shot * clip_size
    local total_time = time_to_empty_clip + reload_time
    if total_time == 0 then return 0 end
    return damage_from_clip / total_time
end
end


-- ====================================================================
-- Compute how much DPS changes when all relevant component scalings are applied.
-- HTML Builder Functions (With Final Fixes)
-- Used for alt-fire rows where explicit DPS/SustainedDPS scaling keys are missing.
-- ====================================================================
local function compute_dps_scaling(hero_data, base_stats, dps_type, scaling_type)
    local scaling_source = hero_data[scaling_type .. "Scaling"] or {}
 
    local component_scalings = {}
    local possible_keys = {
        "BulletDamage", "RoundsPerSecond", "ClipSize", "ReloadTime",
        "ReloadDelay", "BulletsPerShot", "BulletsPerBurst"
    }
    for _, key in ipairs(possible_keys) do
        local alt_key = key .. "AltFire"
        if scaling_source[alt_key] then
            component_scalings[key] = scaling_source[alt_key]
        end
    end
 
    -- ClipSize is shared between fire modes; fall back to primary scaling
    if base_stats.ClipSize and not component_scalings.ClipSize then
        local primary_clip_scale = scaling_source["ClipSize"]
        if primary_clip_scale then
            component_scalings.ClipSize = primary_clip_scale
        end
    end


local function buildMultiplierHtml(type, value, icon_size) -- Unchanged from last version
    if next(component_scalings) == nil then
local config = {
        return 0
spirit = { color = "#E3BDFA", bgColor = "#533669", icon = "Spirit scaling.png", link = "Spirit Power#Spirit Power Scaling", size = icon_size or "40px" },
    end
melee = { color = "#cdb89e", bgColor = "#80550f", icon = "Melee scaling.png", link = "Melee Damage#Abilities", size = icon_size or "40px" },
 
boon = { color = "#bec1ac", bgColor = "#217a68", icon = "Boon scaling.png", link = "Level#Boons from Patron", size = icon_size or "25px" },
    local base_dps = calculate_dps(base_stats, dps_type)
weapon_damage_increase = { color = "#cdb89e", bgColor = "#80550f", icon = "Weapon scaling.png", link = "Weapon Damage", size = icon_size or "40px" }
    local scaled_stats = {}
}
    for k, v in pairs(base_stats) do scaled_stats[k] = v end
local c = config[type]
    for comp_key, scale_val in pairs(component_scalings) do
if not c or not value then return '' end
        if scaled_stats[comp_key] ~= nil then
            scaled_stats[comp_key] = scaled_stats[comp_key] + scale_val
local icon = string.format('[[File:%s|link=%s|%s]]', c.icon, c.link, c.size)
        end
    end
return string.format(
    local scaled_dps = calculate_dps(scaled_stats, dps_type)
'<span style="font-size: 0.8em; color: %s; white-space: nowrap;">%s<span style="background-color: %s; font-family: \'Retail Demo Regular\', \'PT Serif\', \'Palatino\'; font-weight:bold; width: auto; border-radius: 5px; padding: 2px 2px 0px 2px; margin-left: -2px;"><i>x</i>%s</span></span>',
    return scaled_dps - base_dps
c.color, icon, c.bgColor, value
)
end
end


--- FIXED: Replicates the original HeaderAttr template exactly.
-- =========================================================================
local function buildHeaderAttrHtml(value, uom, ss, icon, style)
--  Main module function
if not value or value == '' then return '' end
-- =========================================================================
 
local frame = mw.getCurrentFrame()
p.write_hero_comparison_table = function(frame)
-- The original template used {{Icon/Grey}}, which we can replicate with a span and filter for simplicity,
    local power_increases = tonumber(frame.args[1]) or 0
-- or just output the icon directly if the filter isn't critical. Let's keep it simple.
    local spirit_power    = tonumber(frame.args[2]) or 0
local mainBox = string.format('%s %s',
    local max_power      = tonumber(frame.args[3]) or 25
icon,
    local max_spirit      = tonumber(frame.args[4]) or 500
frame:expandTemplate{ title = "ValueAndUom", args = {
 
"'''" .. value .. "'''",
    local display_scaling_icons = (power_increases == 0 and spirit_power == 0)
uom,
 
uom_style = "font-size: calc(1em - 2px); color: #B2B2B2"
    local body_str = ""
}}
 
)
    local stats_to_include = {
        Weapon = {
            "DPS", "SustainedDPS", "BulletDamage", "RoundsPerSecond", "FireRate",
            "ClipSize", "ReloadTime", "ReloadDelay", "ReloadSingle", "BulletsPerShot", "BulletsPerBurst",
            "BurstInterShotInterval", "LightMeleeDamage", "HeavyMeleeDamage",
            "BulletSpeed", "FalloffStartRange", "FalloffEndRange",
            "CritDamageBonusPercent", "BulletRadius", "RoundsPerSecondAtMaxSpin", "SpinAcceleration", "SpinDeceleration"
        },
        Vitality = {
            "MaxHealth", "BaseHealthRegen", "BulletResist", "TechResist", "MeleeResist",
            "CritDamageReceivedPercent", "DebuffResist", "BulletLifesteal", "MaxMoveSpeed",
            "SprintSpeed", "StaminaCooldown", "Stamina", "GroundDashSpeed", "GravityChange"
        },
        Spirit = { "TechPower" }
    }
 
    -- Collect and sort heroes alphabetically, excluding disabled/in-development
    local sorted_heroes = {}
    for hero_key, hero_data in pairs(heroes_data) do
        if not hero_data["InDevelopment"] and not hero_data["IsDisabled"] then
            table.insert(sorted_heroes, {key = hero_key, data = hero_data})
        end
    end
    table.sort(sorted_heroes, function(a, b)
        local function get_sort_name(name)
            return (name:gsub("^The ", ""))
        end
        return get_sort_name(a.data["Name"] or a.key) < get_sort_name(b.data["Name"] or b.key)
    end)
 
    -- Build a single stat cell (<td>) with data attributes for JS recalculation
    local function buildStatCell(hero_data, hero_key, attr_key, is_alt_fire, category)
        -- Conversion factors for stats that need unit changes (e.g. metres → centimetres)
        local unit_conversion = {
            BulletRadius = 100  -- metres to centimetres
        }
        local conv_factor = unit_conversion[attr_key] or 1
 
        local base_value
        if is_alt_fire then
            if attr_key == "ClipSize" or attr_key == "ReloadTime" then
                base_value = get_nested_stat_value(hero_data, attr_key)
            elseif hero_data.Weapon and hero_data.Weapon.AltFire
                and hero_data.Weapon.AltFire[attr_key] ~= nil then
                base_value = hero_data.Weapon.AltFire[attr_key]
            else
                base_value = get_nested_stat_value(hero_data, attr_key)
            end
        else
            base_value = get_nested_stat_value(hero_data, attr_key)
        end
 
        -- Apply unit conversion to the base value
        if type(base_value) == "number" then
            base_value = base_value * conv_factor
        end
 
        local stat_value = base_value
 
        -- Scaling lookup
        local scaling_data
        if is_alt_fire then
            if attr_key == "ClipSize" or attr_key == "ReloadTime" then
                scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key)
            elseif (attr_key == "DPS" or attr_key == "SustainedDPS")
                and hero_data.Weapon and hero_data.Weapon.AltFire then
                -- Alt-fire DPS/SustainedDPS: use explicit key if present, otherwise compute from components
                local direct_scaling = hero_data_module.get_hero_scaling_data(hero_data, attr_key .. "AltFire")
                if direct_scaling and next(direct_scaling) ~= nil then
                    scaling_data = direct_scaling
                else
                    local alt_stats = hero_data.Weapon.AltFire
                    local dps_type = attr_key == "DPS" and 'burst' or 'sustained'
                    local level_scale = compute_dps_scaling(hero_data, alt_stats, dps_type, "Level")
                    local spirit_scale = compute_dps_scaling(hero_data, alt_stats, dps_type, "Spirit")
                    level_scale = util_module.round_to_sig_fig(level_scale, 5)
                    spirit_scale = util_module.round_to_sig_fig(spirit_scale, 5)
                    scaling_data = {}
                    if level_scale ~= 0 then scaling_data[level_scale] = "Level" end
                    if spirit_scale ~= 0 then scaling_data[spirit_scale] = "Spirit" end
                    if next(scaling_data) == nil then scaling_data = nil end
                end
            elseif hero_data.Weapon and hero_data.Weapon.AltFire
                and hero_data.Weapon.AltFire[attr_key] ~= nil then
                scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key .. "AltFire")
            else
                scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key)
            end
        else
            scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key)
        end
 
        local scaling_strs = ""
        local spirit_scale = 0
        local level_scale  = 0
        local spirit_val = 0
        local level_val  = 0
        if scaling_data ~= nil then
            for scaling_val, scaling_type in pairs(scaling_data) do
                if scaling_type == "Spirit" then
                    spirit_val = scaling_val * conv_factor  -- apply unit conversion
                elseif scaling_type == "Level" then
                    level_val = scaling_val * conv_factor  -- apply unit conversion
                end
            end
 
            -- Output Spirit icon before Level icon
            local function append_scaling(val, stype)
                if val ~= 0 then
                    local scaling_str = hero_data_module.write_scalar_str(val, stype, true)
                    if scaling_str ~= "" then
                        scaling_strs = scaling_strs .. " " .. scaling_str
                    end
                end
            end
            append_scaling(spirit_val, "Spirit")
            append_scaling(level_val, "Level")
 
            if type(stat_value) == "number" then
                stat_value = stat_value + (spirit_power * spirit_val)
                stat_value = stat_value + (power_increases * level_val)
            end


local spiritScale = ''
            spirit_scale = spirit_val
if ss and ss ~= '' then
            level_scale  = level_val
spiritScale = string.format(
        end
'<div style="position: relative;"><div style="position: absolute; top: -24px; right: -14px">%s</div></div>',
buildMultiplierHtml('spirit', ss, '28px')
)
end
return string.format(
'<div style="background-color: #2C2C2C; padding: 10px 6px 10px 6px; margin-right: 20px; white-space: nowrap; %s">%s</div>%s',
style or '', mainBox, spiritScale
)
end


--- FIXED: Removed "overflow: hidden;" to prevent clipping.
        if attr_key == "TechPower" and spirit_scale == 0 then
local function buildMainBoxHtml(prop, hero_key)
            spirit_scale = 1.0
local frame = mw.getCurrentFrame()
            if type(stat_value) == "number" then
local scale_type = prop.Scale and prop.Scale.Type
                stat_value = stat_value + (spirit_power * spirit_scale)
local scale_value = prop.Scale and commonutils.round_to_sig_fig(prop.Scale.Value, 3)
            end
        end
local scale_styles = {
melee = { grad = "radial-gradient(circle, rgba(36,37,36,1) 0%%, rgba(66,55,47,1) 100%%)", border = "#5b412b", shadow = "#3d2c4d" },
spirit = { grad = "radial-gradient(circle, rgba(42,41,43,1) 0%%, rgba(59,49,69,1) 100%%)", border = "#583D6F", shadow = "#3d2c4d" },
power_increase = { grad = "radial-gradient(circle, rgba(42,41,43,1) 0%%, rgba(61,78,70,1) 100%%)", border = "#48675a", shadow = "#3d2c4d" }
}
local current_style = scale_styles[scale_type] or { grad = "#2A2A2A", border = "#2A2A2A", shadow = "#2A2A2A" }
local multiplierHtml = buildMultiplierHtml(scale_type, scale_value)


local icon_data = get_icon(prop.Type)
        local innate_spirit_scale =
local value = prop.Value
            (hero_data["LevelScaling"] and hero_data["LevelScaling"]["TechPower"]) or 0
if scale_type == 'melee' then
local hero_data = mw.loadJsonData("Data:HeroData.json")
local hero = hero_data[hero_key]
if hero and hero.LightMeleeDamage and prop.Scale.Value then
value = prop.Value + hero.LightMeleeDamage * prop.Scale.Value
end
end
local iconHtml = frame:expandTemplate{ title = 'Icon/' .. (icon_data.color or 'Grey'), args = {
string.format('[[File:%s|%s|link=%s]]', icon_data.img, icon_data.size or '18px', icon_data.link),
frame:expandTemplate{ title = 'ValueAndUom', args = {
"'''" .. value .. "'''",
id = prop.Key,
frame:expandTemplate{ title = 'Lang', args = { key = prop.Key .. '_postfix' } },
uom_style = 'font-size: calc(1em - 2px); color: #B2B2B2'
}}
}}


return string.format(
        if not display_scaling_icons then scaling_strs = "" end
-- REMOVED "overflow: hidden;" FROM THE FIRST DIV'S STYLE
'<div style="display:flex; flex-direction:column; flex-grow: 1; flex-basis: 0; max-height: 100%%; padding: 10px 0px 10px 0px; margin: 3px;">' ..
'  %s' ..
'  <div style="position: relative;"><div style="position: absolute; top: -20px; right: -2px">%s</div></div>' ..
'  <div style="display:flex; flex-direction:column; flex-grow: 1; justify-content: space-between; background: %s; text-align: center; border: 3px solid %s; box-shadow: 0 0 10px %s; border-radius: 1px">' ..
'    <div style="font-weight: bold;">%s</div>' ..
'    <div style="font-size: 0.8rem; padding-bottom: 3px">%s</div>' ..
'  </div>' ..
'</div>',
prop.Title and string.format('<div style="padding-bottom: 2px; font-size: 0.8rem; color: #9C9C9C; font-variant: small-caps;"><b>%s</b></div>', prop.Title) or '',
multiplierHtml,
current_style.grad,
current_style.border,
current_style.shadow,
iconHtml,
frame:expandTemplate{ title = 'Lang', args = { key = prop.Key .. '_label' }}
)
end


local buildAltBoxHtml = function(...) end -- Forward declare
        if type(stat_value) == "boolean" then
local buildUpgradeBoxHtml = function(...) end -- Forward declare
            stat_value = tostring(stat_value)
        else
            stat_value = util_module.round_to_sig_fig(stat_value, 5)
        end


-- ====================================================================
        local cell_inner = string.format(
-- Main Entry Point (With Final Fixes)
            '<span class="stat-num">%s</span><span class="stat-scaling">%s</span>',
-- ====================================================================
            stat_value,
            scaling_strs
        )


function p.get_ability_card(frame_args)
        local weapon_table = hero_data.Weapon
local frame = mw.getCurrentFrame()
        if is_alt_fire and weapon_table and weapon_table.AltFire then
local args = frame_args.args
            weapon_table = weapon_table.AltFire
        end
        local hit_once = "false"
        if (attr_key == "DPS" or attr_key == "SustainedDPS")
            and weapon_table
            and weapon_table.HitOnceAcrossAllBullets
        then
            hit_once = "true"
        end


local hero_name = args[1]
        local data_attrs = string.format(
local ability_num = args[2]
            '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"',
local add_link = args[3] == 'true'
            attr_key,
local notes = args[4]
            tostring(type(base_value) == "number" and util_module.round_to_sig_fig(base_value, 3) or base_value),
            tonumber(spirit_scale) or 0,
local hero_key = get_hero_key(hero_name)
            (attr_key == "TechPower") and "0" or (tonumber(level_scale) or 0),
if not hero_key then return 'Hero with name "' .. tostring(hero_name) .. '" not found' end
            innate_spirit_scale or 0,
            stat_value,
local ability = utils.get_ability_card_data(hero_key, ability_num)
            hit_once
if not ability then return 'Ability data not found for hero ' .. tostring(hero_key) .. ' and num ' .. tostring(ability_num) end
        )
local html = {}
local ability_name_localized = lang.get_string(ability.Key)
local name_link_target = add_link and ability_name_localized or nil
-- Header Section
local header_attrs_left = {}
local cast_time_data = ability.Cast and ability.Cast.AbilityChannelTime
table.insert(header_attrs_left, buildHeaderAttrHtml(cast_time_data and cast_time_data.Value, 's', get_attr_ss(cast_time_data), '[[File:AttributeIconMaxChargesIncrease.png|Channel Time|20px|link=]]'))
table.insert(header_attrs_left, buildHeaderAttrHtml(ability.AbilityCastRange and ability.AbilityCastRange.Value, 'm', get_attr_ss(ability.AbilityCastRange), '[[File:CastRange.png|Cast Range|20px|link=]]'))
table.insert(header_attrs_left, buildHeaderAttrHtml(ability.Radius and ability.Radius.Value, 'm', get_attr_ss(ability.Radius), '[[File:AttributeIconTechRange.png|Radius|20px|link=]]'))
table.insert(header_attrs_left, buildHeaderAttrHtml(ability.AbilityDuration and ability.AbilityDuration.Value, 's', get_attr_ss(ability.AbilityDuration), '[[File:AttributeIconTechDuration.png|Duration|20px|link=]]'))


local header_attrs_right_top = {}
        return string.format('<td style="white-space: nowrap;" %s>%s</td>', data_attrs, cell_inner)
table.insert(header_attrs_right_top, buildHeaderAttrHtml(ability.AbilityCharges and ability.AbilityCharges.Value, '', nil, '[[File:AttributeIconMaxChargesIncrease.png|Number of Charges|20px|link=]]', 'margin-right: 0;'))
    end
table.insert(header_attrs_right_top, buildHeaderAttrHtml(ability.AbilityCooldownBetweenCharge and ability.AbilityCooldownBetweenCharge.Value, 's', get_attr_ss(ability.AbilityCooldownBetweenCharge), '[[File:AttributeIconTechCooldownBetweenChargeUses.png|Charge Cooldown|16px|link=]]', 'margin-left: 1px; margin-right: 0;'))
local header_attrs_right_bottom = buildHeaderAttrHtml(ability.AbilityCooldown and ability.AbilityCooldown.Value, 's', get_attr_ss(ability.AbilityCooldown), '[[File:AttributeIconTechCooldown.png|Cooldown|20px|link=]]')


table.insert(html, string.format(
    -- Generate a hero's primary stat row
'<div style="width: 100%%;"><div style="display:flex; flex-direction:row; justify-content: space-between; background: #121212; font-size: calc(1em - 2px); border-radius: 13px 13px 0 0; padding: 0 8px 14px 8px;">' ..
    local function generatePrimaryRow(hero_data, hero_key, has_alt)
'  <div style="display:flex; flex-direction:column;">' ..
        local row_str = ""
'    <div style="font-size: 1.3rem;"><span style="filter: brightness(0) saturate(100%%) invert(98%%) sepia(19%%) saturate(1458%%) hue-rotate(301deg) brightness(102%%) contrast(109%%); padding: 0 5px 0 7px">[[File:%s.png|45px|link=]]</span><span style="font-family:\'Retail Demo\',\'Open Sans\'; font-weight:bold">%s</span></div>' ..
        local hero_name_local = localize(hero_key, hero_key)
'    <div style="display:flex; flex-direction:row; margin-left: 10px; height: 40px;">%s</div>' ..
        local hero_name_en = hero_data["Name"]
'  </div>' ..
        local template_args = {[1] = hero_name_en, l1 = hero_name_local}
' <div style="display:flex; flex-direction:column; align-items: end; justify-content: flex-end;">' ..
        local hero_icon = mw.getCurrentFrame():expandTemplate{ title = "Template:HeroIcon", args = template_args }
'    <div style="display:flex; flex-direction:row; justify-content: flex-end; margin-bottom: 5px; margin-right: 20px;">%s</div>' ..
        local hero_cell_content = hero_icon
'    %s' ..
        if has_alt then
' </div>' ..
            hero_cell_content = '[[#alt-fire-' .. hero_key .. '|+]] ' .. hero_cell_content
'</div></div>',
        end
lang.get_string(ability.Key, 'en'),
        local sort_name = (hero_data["Name"] or hero_key):gsub("^The ", "")
name_link_target and string.format('[[%s|%s]]', name_link_target, ability_name_localized) or ability_name_localized,
        row_str = row_str .. '<td style="position: sticky; left: 0; z-index: 10; background-color: var(--background-color-base-2); isolation: isolate; overflow: hidden; min-width: 150px;" data-sort-value="' .. sort_name .. '">' .. hero_cell_content .. '</td>'
table.concat(header_attrs_left),
        for _, category in ipairs(attribute_orders["category_order"]) do
table.concat(header_attrs_right_top),
            if stats_to_include[category] ~= nil then
header_attrs_right_bottom
                for _, attr_key in ipairs(stats_to_include[category]) do
))
                    row_str = row_str .. buildStatCell(hero_data, hero_key, attr_key, false, category)
                end
-- Info Sections
            end
table.insert(html, '<div style="margin: 5px 10px 0px 10px; padding: 3px 0 5px 0; width: calc(100%% - 18px); box-sizing: unset;">')
        end
for i = 1, 3 do
        return "<tr>" .. row_str .. "</tr>"
local info_section = ability['Info' .. i]
    end
if info_section then
local desc = (info_section.DescKey and lang.get_string(info_section.DescKey)) or ''
local main_boxes, alt_boxes = {}, {}
if info_section.Main and info_section.Main.Props then
for _, prop in ipairs(info_section.Main.Props) do
if prop.Value and prop.Value ~= 0 then
table.insert(main_boxes, buildMainBoxHtml(prop, hero_key))
end
end
end
if info_section.Alt then
for _, prop in ipairs(info_section.Alt) do
if prop.Value and prop.Value ~= 0 then
table.insert(alt_boxes, buildAltBoxHtml(prop))
end
end
end


if desc ~= '' or #main_boxes > 0 or #alt_boxes > 0 then
    -- Generate an expandable alt-fire sub-row (weapon stats only)
local alt_box_container = ''
    local function generateAltFireSubRow(hero_data, hero_key)
if #alt_boxes > 0 then
        local row_str = ""
-- FIXED: Added the missing wrapper div for alt boxes.
        local hero_name_local = localize(hero_key, hero_key)
alt_box_container = string.format(
        local hero_name_en = hero_data["Name"]
'<div style="display:flex; flex-direction:row; flex-wrap: wrap; justify-content: center; align-items: center; background-color: #2A2A2A; width: calc(97%% - 19px); min-width: 267px; padding: 0 4px 0 4px; column-gap: 15px">%s</div>',
        local template_args = {[1] = hero_name_en, l1 = hero_name_local}
table.concat(alt_boxes)
        local hero_icon = mw.getCurrentFrame():expandTemplate{ title = "Template:HeroIcon", args = template_args }
)
        local hero_cell_content = hero_icon .. ' <small style="color:#aaa;">(Alt‑fire)</small>'
end
        local sort_name = (hero_data["Name"] or hero_key):gsub("^The ", "")
        row_str = row_str .. '<td style="position: sticky; left: 0; z-index: 9; background-color: #202122; isolation: isolate; overflow: hidden; min-width: 150px;" data-sort-value="' .. sort_name .. '">' .. hero_cell_content .. '</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
                    if category == "Weapon" then
                        row_str = row_str .. buildStatCell(hero_data, hero_key, attr_key, true, category)
                    else
                        row_str = row_str .. '<td></td>'
                    end
                end
            end
        end
        return '<tr class="alt-fire-sub" data-parent="' .. hero_key .. '">' .. row_str .. '</tr>'
    end


table.insert(html, string.format(
    -- Build all body rows
'<div style="display:flex; flex-direction:column; align-items: center; width: 100%%; padding-top: 8px;">' ..
    for _, hero_entry in ipairs(sorted_heroes) do
'  <div style="padding: 0 10px 10px 10px">%s</div>' ..
        local hero_key = hero_entry.key
'  <div style="display:flex; flex-direction:row; flex-wrap: wrap; justify-content: center; width: calc(97%% - 5px); min-width: 280px;">%s</div>' ..
        local hero_data = hero_entry.data
' %s' ..
        local has_alt = hero_data.Weapon and hero_data.Weapon.AltFire and true or false
'</div>',
        body_str = body_str .. generatePrimaryRow(hero_data, hero_key, has_alt)
frame:preprocess(desc),
        if has_alt then
table.concat(main_boxes),
            body_str = body_str .. generateAltFireSubRow(hero_data, hero_key)
alt_box_container
        end
))
    end
end
end
end
table.insert(html, '</div>')
-- Upgrades Section
local upgrade_boxes = {}
if ability.Upgrades then
for i, prop in ipairs(ability.Upgrades) do
table.insert(upgrade_boxes, buildUpgradeBoxHtml(prop, i))
end
end
table.insert(html, string.format(
'<div style="display:flex; flex-direction:row; flex-wrap: wrap; margin-bottom: 5px; justify-content: center; width: calc(97%% - 16px); min-width: 280px;">%s</div>',
table.concat(upgrade_boxes)
))
-- Notes Section
if notes and notes ~= '' then
table.insert(html, string.format(
'<div style="align-self: flex-start; margin: 10px 0 10px 20px; font-size: 0.8rem;">'..
'<div><span style="font-size:1.15rem;">%s</span></div>' ..
'<div class="mw-collapsible mw-collapsed" style="margin-top: 5px;">' ..
'<div>%s</div>' ..
'</div></div>',
frame:callParserFunction( '#invoke', 'Dictionary', 'translate', 'Notes' ),
frame:preprocess(notes)
))
end


local final_card = string.format(
    -- Pre-pass: determine which stats have any scaling, and which have both types.
'{{anchor|%s}}<div style="font-size: 0.9rem; line-height: 1.4; font-family: \'Retail Demo\', \'Open Sans\', \'PT Serif\', serif; color: #FFEFD7; display:flex; flex-direction:column; background: linear-gradient(90deg, rgba(52,52,52,1) 0%%, rgba(66,66,66,1) 14%%, rgba(77,77,77,1) 100%%); align-items: center; width: 100%%; max-width: 500px; border-radius: 13px 13px 4px 4px;">%s</div>',
    -- Used to set column widths in the header.
ability_name_localized,
    local stats_with_any_scaling  = {}
table.concat(html, '\n')
    local stats_with_both_scaling = {}
)
    for _, hero_entry in ipairs(sorted_heroes) do
        local hero_data = hero_entry.data
-- FIXED: Preprocess the entire output to render the anchor and other wikitext.
        for _, category in ipairs(attribute_orders["category_order"]) do
return frame:preprocess(final_card)
            if stats_to_include[category] ~= nil then
end
                for _, attr_key in ipairs(stats_to_include[category]) do
                    -- Primary scaling
                    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, has_level = false, false
                        for _, scaling_type in pairs(scaling_data) do
                            if scaling_type == "Spirit" then has_spirit = true
                            elseif scaling_type == "Level" then has_level = true end
                        end
                        if has_spirit and has_level then stats_with_both_scaling[attr_key] = true end
                    end


-- We re-paste the remaining builder functions here so the module is complete in one block.
                    -- Alt-fire scaling (only for stats present in the AltFire table)
buildAltBoxHtml = function(prop)
                    if hero_data.Weapon
local frame = mw.getCurrentFrame()
                        and hero_data.Weapon.AltFire
local scaleHtml = ''
                        and hero_data.Weapon.AltFire[attr_key] ~= nil
if prop.Scale and prop.Scale.Type and prop.Scale.Value then
                    then
scaleHtml = '&nbsp;' .. buildMultiplierHtml(prop.Scale.Type, commonutils.round_to_sig_fig(prop.Scale.Value, 3), '35px')
                        if attr_key ~= "ClipSize" then
end
                            local alt_scaling = hero_data_module.get_hero_scaling_data(hero_data, attr_key .. "AltFire")
                            if alt_scaling and next(alt_scaling) ~= nil then
local icon_data = get_icon(prop.Type)
                                stats_with_any_scaling[attr_key] = true
local iconHtml = frame:expandTemplate{ title = 'Icon/' .. (icon_data.color or 'Grey'), args = {
                                local has_spirit_alt, has_level_alt = false, false
string.format('[[File:%s|%s|link=%s]]', icon_data.img, icon_data.size or '18px', icon_data.link),
                                for _, stype in pairs(alt_scaling) do
frame:expandTemplate{ title = 'ValueAndUom', args = {
                                    if stype == "Spirit" then has_spirit_alt = true
"'''" .. prop.Value .. "'''",
                                    elseif stype == "Level" then has_level_alt = true end
id = prop.Key,
                                end
frame:expandTemplate{ title = 'Lang', args = { key = prop.Key .. '_postfix' } },
                                if has_spirit_alt and has_level_alt then stats_with_both_scaling[attr_key] = true end
uom_style = 'font-size: 10px; color: #9C9C9C'
                            else
}}
                                if attr_key == "DPS" or attr_key == "SustainedDPS" then
}}
                                    local alt_stats = hero_data.Weapon.AltFire
                                    local dps_type = attr_key == "DPS" and 'burst' or 'sustained'
return string.format(
                                    local lv = compute_dps_scaling(hero_data, alt_stats, dps_type, "Level")
'<div style="display:flex; flex-direction:row; flex-grow: 1; flex-basis: 0; justify-content: left; background-color: #2A2A2A; font-size: 0.85em; margin: 3px; text-align: center; box-shadow: 0 0 10px #2A2A2A; border-radius: 1px; white-space: nowrap">' ..
                                    local sp = compute_dps_scaling(hero_data, alt_stats, dps_type, "Spirit")
'  <span style="white-space: nowrap">%s&nbsp;<span style="font-size: 0.75rem; white-space: nowrap">%s%s</span></span>' ..
                                    lv = util_module.round_to_sig_fig(lv, 5)
'</div>',
                                    sp = util_module.round_to_sig_fig(sp, 5)
iconHtml,
                                    if lv ~= 0 or sp ~= 0 then
frame:expandTemplate{ title = 'Lang', args = { key = prop.Key .. '_label' }},
                                        stats_with_any_scaling[attr_key] = true
scaleHtml
                                        if lv ~= 0 and sp ~= 0 then
)
                                            stats_with_both_scaling[attr_key] = true
end
                                        end
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end


UPGRADE_COST_MAP = {1, 2, 5}
    -- Build table header row
buildUpgradeBoxHtml = function(prop, index)
    local headers_str = '<th style="position: sticky; left: 0; top: -1px; z-index: 12; background-color: var(--background-color-base-5); isolation: isolate; min-width: 150px;">Hero</th>'
local frame = mw.getCurrentFrame()
    local category_data = attribute_module.get_category_data()
local description = lang.get_string(prop.DescKey)
    local postfix_key_map = {
        ["ReloadDelay"] = "StatDesc_ReloadTime_postfix",
if (description == nil or description == '') then
        ["BulletsPerShot"] = "",
local desc_parts = {}
        ["BulletsPerBurst"] = "",
local seen = {}
        ["BurstInterShotInterval"] = "StatDesc_ReloadTime_postfix",
for k, v in pairs(prop) do
        ["ReloadSingle"] = "",
if type(v) ~= 'table' then
        ["BonusAttackRange"] = "StatDesc_WeaponRangeFalloffMax_postfix",
local formatted_value = utils.format_value_with_prepost(k, v, frame)
        ["SustainedDPS"] = "DPS_postfix",
local attr_name = lang.get_string(k..'_label')
        ["RoundsPerSecondAtMaxSpin"] = "",
local key = formatted_value .. '|' .. attr_name
        ["CritDamageBonusPercent"]  = "StatDesc_CritDamageBonusScale_postfix",
if not seen[key] then
["CritDamageReceivedPercent"] = "StatDesc_CritDamageReceivedScale_postfix",
table.insert(desc_parts, string.format('%s %s', formatted_value, attr_name))
        ["BulletLifesteal"] = "BulletLifestealPercentHero_postfix",
seen[key] = true
        ["GroundDashSpeed"] = "DashSpeed_postfix"
end
    }
end
    for _, category in ipairs(attribute_orders["category_order"]) do
end
        local category_attrs = attributes_data[category]
description = table.concat(desc_parts, '<br>')
        local category_rgb  = category_data[category]["rgb"]
end
        if stats_to_include[category] ~= nil then
            for _, attr_key in ipairs(stats_to_include[category]) do
                local attr_data = category_attrs[attr_key]
               
                -- Fallback: if the stat is missing from this category, check all other categories
                if attr_data == nil then
                    for _, fallback_cat_data in pairs(attributes_data) do
                        if fallback_cat_data[attr_key] ~= nil then
                            attr_data = fallback_cat_data[attr_key]
                            break
                        end
                    end
                end


local d_len = mw.ustring.len(description)
                local attr_localized, postfix
local fontsize = '1em'
                if attr_data ~= nil then
if d_len > 60 and d_len < 71 then fontsize = '0.95rem'
                    attr_localized = lang_module.get_string(attr_data["label"])
elseif d_len > 70 and d_len < 91 then fontsize = '0.875rem'
                    if attr_localized == nil or attr_localized == "" then
elseif d_len > 90 then fontsize = '0.8rem'
                        attr_localized = util_module.add_space_before_cap(attr_key)
end
                    end
                    postfix = lang_module.get_string(attr_data["postfix"])
                    if postfix == nil or postfix == "" then postfix = ""
                    else postfix = " (" .. postfix .. ")" end
                    if attr_key == "BulletRadius" then postfix = " (cm)" end
                else
                    attr_localized = dictionary_module.translate(attr_key)
                    postfix = lang_module.get_string(postfix_key_map[attr_key])
                    if postfix == nil then return "attr_key " .. attr_key .. " must be added to postfix_key_map" end
                    if postfix ~= "" then postfix = " (" .. postfix .. ")" end
                end
                local th_style = 'position: sticky; top: -1px; z-index: 3; background-color: rgb(' .. category_rgb .. ');'
                if stats_with_both_scaling[attr_key] then
                    th_style = th_style .. ' min-width: 130px;'
                elseif stats_with_any_scaling[attr_key] then
                    th_style = th_style .. ' min-width: 75px;'
                end
                headers_str = headers_str .. '<th style="' .. th_style .. '">' .. attr_localized .. postfix .. "</th>"
            end
        end
    end
    headers_str = "<tr>" .. headers_str .. "</tr>"


local scaleHtml = ''
    return string.format(
if prop.Scale and prop.Scale.Type and prop.Scale.Value then
        '<div id="hero-comparison-container" data-max-power="%s" data-max-spirit="%s">' ..
scaleHtml = buildMultiplierHtml(prop.Scale.Type, commonutils.round_to_sig_fig(prop.Scale.Value, 3))
        '<div style="overflow: auto; max-height: 70vh; width: 100%%;">' ..
end
        '<table class="wikitable sortable" style="table-layout: auto; width: 100%%;" id="hero-comparison-table">%s%s</table>' ..
        '</div></div>',
return string.format(
        max_power, max_spirit, headers_str, body_str
'<div style="display:flex; flex-direction:column; flex-grow: 1; flex-basis: 0; justify-content: flex-start; max-height: 140px; background-color: #555555; padding: 0 0 20px 0px; margin: 6px; text-align: center; box-shadow: 0 0 10px #2A2A2A; border-radius: 5px">' ..
    )
' <div style="padding: 2px 0 0 2px; font-family:Retail Demo bold; font-size: clamp(0.70em, 2vw, 1em); background-color: #121212; border-radius: 5px 5px 0 0; ">%s</div>' ..
' <div style="padding: 10px 4px 4px 4px; font-size: clamp(0.70em, 2vw, %s);">%s</div>' ..
' <div>%s</div>' ..
'</div>',
frame:expandTemplate{ title = 'Ap', args = { UPGRADE_COST_MAP[index], icon_size = '15px' } },
fontsize,
frame:preprocess(description),
scaleHtml
)
end
end


return p
return p