Module:Abilities/card: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Added support for melee scaling attributes to show the outcome with the hero's base melee value
Vergir (talk | contribs)
m undo the undo, it works now
Tag: Undo
 
(59 intermediate revisions by 9 users not shown)
Line 2: Line 2:
local commonutils = require "Module:Utilities"
local commonutils = require "Module:Utilities"
local utils = require "Module:Abilities/utils"
local utils = require "Module:Abilities/utils"
local Icon = require"Module:Abilities/icon"
local textfit = require "Module:TextFit"


local p = {}
local p = {}
local data = mw.loadJsonData("Data:AbilityCards.json")
local data = mw.loadJsonData("Data:AbilityCards.json")


-- Maps attr type to an appropriate image and page link
-- Optional icon size, will be defaulted on the template
local ATTR_TYPE_ICON_MAP = {
bullet_armor_up = {img='Bullet_Armor.png', link='Damage_Resistance', color = 'NoColor'},
bullet_armor_down = {img='Bullet_Armor.png', link='Damage_Resistance', color = 'NoColor'},
cast = {img='AttributeIconMaxChargesIncrease.png', link=''},
charges = {img='AttributeIconMaxChargesIncrease.png', link=''},
damage = {img='Damage_heart.png', link='', color = 'NoColor'}, -- 'NoColor' will apply no icon color and use the original image
bullet_damage = {img='Bullet_damage.png', link='Bullet_Damage', color = 'NoColor'},
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'},
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'},
duration = {img='AttributeIconTechDuration.png', link='Ability Duration'},
slow = {img='MoveSlow.png', link=''},
melee_damage = {img='Melee damage.png', link='Melee Damage'},
cooldown = {img='AttributeIconTechDuration.png', link='Ability Cooldown'},
combat_barrier = {img='Barrier.png', link='Barrier', color = 'Green'},
}


function get_hero_key(hero_name)
function get_hero_key(hero_name)
for i, hero in pairs(data) do
if hero_name == nil then return nil end
if hero["Name"] == hero_name then
return i
-- if name starts with "hero_", use it directly as the key
end
if string.sub(hero_name, 1, 5) == "hero_" then return hero_name end
end
return nil
local resource_lookup = mw.loadJsonData("Data:ResourceLookup.json")
return resource_lookup[hero_name:lower()].key
end
end


Line 83: Line 63:
local info1_desc = get_info_desc(hero_key, ability_num, 1)
local info1_desc = get_info_desc(hero_key, ability_num, 1)
local info1_main_boxes = get_main_boxes(hero_key, ability_num, 1)
local info1_main_boxes = get_main_boxes(hero_key, ability_num, 1)
-- Inject Hex Duration only for Rabbit Hex
if ability.Key == "ability_magician_animalhexarea"
  and ability.Other
  and ability.Other.HexDuration
  and ability.Other.HexDuration.Value then
    local icon = get_icon("duration")
    table.insert(info1_main_boxes, 1,
        frame:expandTemplate{
            title = "Ability_card_v2/Card/MainBox",
            args = {
                key = "HexDuration",
                value = ability.Other.HexDuration.Value,
                icon = icon.img,
                icon_link = icon.link,
                icon_color = icon.color,
            }
        })
end


local info1_alt_boxes = get_alt_boxes(hero_key, ability_num, 1)
local info1_alt_boxes = get_alt_boxes(hero_key, ability_num, 1)
if #info1_alt_boxes > 6 then
if #info1_alt_boxes > 7 then
error(#info1_alt_boxes .. ' alt boxes found, but only 6 are supported. Please update Module:Abilities/card and Template:Ability_card_v2')
error(#info1_alt_boxes .. ' alt boxes found, but only 7 are supported. Please update Module:Abilities/card and Template:Ability_card_v2')
end
end
Line 113: Line 74:
local info2_alt_boxes = get_alt_boxes(hero_key, ability_num, 2)
local info2_alt_boxes = get_alt_boxes(hero_key, ability_num, 2)
if #info2_alt_boxes > 6 then
if #info2_alt_boxes > 7 then
error(#info2_alt_boxes .. ' alt boxes found, but only 6 are supported. Please update Module:Abilities/card and Template:Ability_card_v2')
error(#info2_alt_boxes .. ' alt boxes found, but only 7 are supported. Please update Module:Abilities/card and Template:Ability_card_v2')
end
end
Line 121: Line 82:
local info3_alt_boxes = get_alt_boxes(hero_key, ability_num, 3)
local info3_alt_boxes = get_alt_boxes(hero_key, ability_num, 3)
if #info3_alt_boxes > 6 then
if #info3_alt_boxes > 7 then
error(#info3_alt_boxes .. ' alt boxes found, but only 6 are supported. Please update Module:Abilities/card and Template:Ability_card_v2')
error(#info3_alt_boxes .. ' alt boxes found, but only 7 are supported. Please update Module:Abilities/card and Template:Ability_card_v2')
end
end
Line 130: Line 91:
title = "Ability_card_v2/Card",
title = "Ability_card_v2/Card",
args = {
args = {
id = hero_key .. ':' .. ability.Key,
hero_key = hero_key,
hero_key = hero_key,
ability_num = ability_num,
ability_num = ability_num,
Line 138: Line 100:
icon = lang.get_string(ability.Key, 'en') .. '.png',
icon = lang.get_string(ability.Key, 'en') .. '.png',
channel_time = ability.Cast and ability.Cast.AbilityChannelTime and ability.Cast.AbilityChannelTime.Value,
channel_time = ability.AbilityChannelTime and ability.AbilityChannelTime.Value ~= 9999 and ability.AbilityChannelTime.Value,
channel_time_ss = ability.Cast and get_attr_ss(ability.Cast.AbilityChannelTime),
channel_time_scale = get_attr_scale(ability.AbilityChannelTime),
channel_time_scale_type = get_attr_scale_type(ability.AbilityChannelTime),
channel_time_scale_multiply = get_attr_scale_multiply(ability.AbilityChannelTime),
radius = ability.Radius and ability.Radius.Value,
radius = ability.Radius and ability.Radius.Value,
radius_ss = get_attr_ss(ability.Radius),
radius_scale = get_attr_scale(ability.Radius),
radius_scale_type = get_attr_scale_type(ability.Radius),
radius_scale_multiply = get_attr_scale_multiply(ability.Radius),
range = ability.AbilityCastRange and ability.AbilityCastRange.Value,
range = ability.AbilityCastRange and ability.AbilityCastRange.Value,
range_ss = get_attr_ss(ability.AbilityCastRange),
range_scale = get_attr_scale(ability.AbilityCastRange),
range_scale_type = get_attr_scale_type(ability.AbilityCastRange),
range_scale_multiply = get_attr_scale_multiply(ability.AbilityCastRange),
duration = ability.AbilityDuration and ability.AbilityDuration.Value,
duration = ability.AbilityDuration and ability.AbilityDuration.Value,
duration_ss = get_attr_ss(ability.AbilityDuration),
duration_scale = get_attr_scale(ability.AbilityDuration),
duration_scale_type = get_attr_scale_type(ability.AbilityDuration),
duration_scale_multiply = get_attr_scale_multiply(ability.AbilityDuration),
-- ability_width = format_value_with_prepost(width_key, ability[width_key]),
-- ability_width = format_value_with_prepost(width_key, ability[width_key]),
cooldown =ability.AbilityCooldown and ability.AbilityCooldown.Value,
cooldown =ability.AbilityCooldown and ability.AbilityCooldown.Value,
cooldown_ss = get_attr_ss(ability.AbilityCooldown),
cooldown_scale = get_attr_scale(ability.AbilityCooldown),
charge_cooldown = ability.AbilityCooldownBetweenCharge and ability.AbilityCooldownBetweenCharge.Value,
cooldown_scale_type = get_attr_scale_type(ability.AbilityCooldown),
charge_cooldown_ss = get_attr_ss(ability.AbilityCooldownBetweenCharge),
cooldown_scale_multiply = get_attr_scale_multiply(ability.AbilityCooldown),
num_of_charges = ability.AbilityCharges and ability.AbilityCharges.Value,
charge_cooldown = ability.AbilityCooldownBetweenCharge and ability.AbilityCooldownBetweenCharge.Value ~= -1 and ability.AbilityCooldownBetweenCharge.Value,
charge_cooldown_scale = get_attr_scale(ability.AbilityCooldownBetweenCharge),
charge_cooldown_scale_type = get_attr_scale_type(ability.AbilityCooldownBetweenCharge),
charge_cooldown_scale_multiply = get_attr_scale_multiply(ability.AbilityCooldownBetweenCharge),
num_of_charges = ability.AbilityCharges and ability.AbilityCharges.Value or 0,
-- Info section #1 defined in "Info1" attribute
-- Info section #1 defined in "Info1" attribute
hide_info1 = infobox_is_hidden(ability.Info1) and "true" or "false",
info1_desc = info1_desc,
info1_desc = info1_desc,
info1_mainbox1 = info1_main_boxes[1],
info1_mainbox1 = info1_main_boxes[1],
info1_mainbox2 = info1_main_boxes[2],
info1_mainbox2 = info1_main_boxes[2],
info1_mainbox3 = info1_main_boxes[3],
info1_mainbox3 = info1_main_boxes[3],
info1_mainbox4 = info1_main_boxes[4],
info1_mainbox5 = info1_main_boxes[5],
info1_altbox1 =  info1_alt_boxes[1],
info1_altbox1 =  info1_alt_boxes[1],
info1_altbox2 =  info1_alt_boxes[2],
info1_altbox2 =  info1_alt_boxes[2],
Line 164: Line 141:
info1_altbox5 =  info1_alt_boxes[5],
info1_altbox5 =  info1_alt_boxes[5],
info1_altbox6 =  info1_alt_boxes[6],
info1_altbox6 =  info1_alt_boxes[6],
info1_altbox7 =  info1_alt_boxes[7],


-- Info section #2 defined in "Info2" attribute
-- Info section #2 defined in "Info2" attribute
hide_info2 = infobox_is_hidden(ability.Info2) and "true" or "false",
info2_desc = info2_desc,
info2_desc = info2_desc,
info2_mainbox1 = info2_main_boxes[1],
info2_mainbox1 = info2_main_boxes[1],
info2_mainbox2 = info2_main_boxes[2],
info2_mainbox2 = info2_main_boxes[2],
info2_mainbox3 = info2_main_boxes[3],
info2_mainbox3 = info2_main_boxes[3],
info2_mainbox4 = info2_main_boxes[4],
info2_mainbox5 = info2_main_boxes[5],
info2_altbox1 =  info2_alt_boxes[1],
info2_altbox1 =  info2_alt_boxes[1],
info2_altbox2 =  info2_alt_boxes[2],
info2_altbox2 =  info2_alt_boxes[2],
Line 176: Line 157:
info2_altbox5 =  info2_alt_boxes[5],
info2_altbox5 =  info2_alt_boxes[5],
info2_altbox6 =  info2_alt_boxes[6],
info2_altbox6 =  info2_alt_boxes[6],
info2_altbox7 =  info2_alt_boxes[7],
 
-- Info section #3 defined in "Info3" attribute
-- Info section #3 defined in "Info3" attribute
hide_info3 = infobox_is_hidden(ability.Info3) and "true" or "false",
info3_desc = info3_desc,
info3_desc = info3_desc,
info3_mainbox1 = info3_main_boxes[1],
info3_mainbox1 = info3_main_boxes[1],
info3_mainbox2 = info3_main_boxes[2],
info3_mainbox2 = info3_main_boxes[2],
info3_mainbox3 = info3_main_boxes[3],
info3_mainbox3 = info3_main_boxes[3],
info3_mainbox4 = info3_main_boxes[4],
info3_mainbox5 = info3_main_boxes[5],
info3_altbox1 =  info3_alt_boxes[1],
info3_altbox1 =  info3_alt_boxes[1],
info3_altbox2 =  info3_alt_boxes[2],
info3_altbox2 =  info3_alt_boxes[2],
Line 188: Line 173:
info3_altbox5 =  info3_alt_boxes[5],
info3_altbox5 =  info3_alt_boxes[5],
info3_altbox6 =  info3_alt_boxes[6],
info3_altbox6 =  info3_alt_boxes[6],
info3_altbox7 =  info3_alt_boxes[7],
 
-- Ability upgrades defined in "Upgrades" attribute
-- Ability upgrades defined in "Upgrades" attribute
upgrade1 = upgrades[1],
upgrade1 = upgrades[1],
Line 201: Line 187:
end
end


-- Get spirit scaling of attribute, return nil if none is found
function infobox_is_hidden(infobox)
function get_attr_ss(attr)
if infobox == nil then
if not attr then  
return false
return nil  
end
return infobox.RequiresUpgradeIndex ~= nil
end
 
-- An attribute may carry either a single scaling or a list of them.
-- Extra entries are stat-category markers (duration, cooldown, range, radius,
-- healing, charges) that always hold a value of 1 and are never displayed, so
-- return the first entry that actually renders a badge.
-- A zero-valued scaling is still returned when nothing else is displayable. The
-- card renders it as an x0 placeholder, and the ability upgrade gadget fills
-- that element in when an upgrade grants real scaling (Doorman's Doorway).
local function pick_scale(attr)
if not attr then
return nil
end
end
local scale = attr.Scale
local scale = attr.Scale
if type(scale) ~= 'table' then
return nil
end
-- A single scaling is stored directly as an object
if scale[1] == nil then
return scale
end
local placeholder = nil
for _, entry in ipairs(scale) do
if type(entry) == 'table' and Icon.isDisplayedScale(entry.Type) then
if entry.Value ~= 0 then
return entry
end
placeholder = placeholder or entry
end
end
return placeholder
end
-- Get scaling of attribute, return nil if none is found
function get_attr_scale(attr)
local scale = pick_scale(attr)
if not scale then
if not scale then
return nil
return nil
end
end
if scale.Type ~= 'spirit' then
if scale.Value == 0 then
return nil
return nil
end
return commonutils.round_to_sig_fig(scale.Value, 3)
end
-- Get scaling type of attribute, return nil if none is found
function get_attr_scale_type(attr)
local scale = pick_scale(attr)
if not scale then
return nil
end
end
Line 220: Line 255:
end
end
return commonutils.round_to_sig_fig(scale.Value, 3)
return scale.Type
end
 
-- Check if scaling should be multiplied instead of added
function get_attr_scale_multiply(attr)
local scale = pick_scale(attr)
if not scale then
return nil
end
return scale.Multiply
end
end


Line 231: Line 276:
local ability = utils.get_ability_card_data(hero_key, ability_num)
local ability = utils.get_ability_card_data(hero_key, ability_num)
if(ability == nil) then return "Ability Not Found" end
if(ability == nil) then return {} end -- return empty table, not string
local info_section = ability['Info'..info_section_num]
local info_section = ability['Info'..info_section_num]
Line 271: Line 316:
local main = info_section.Main
local main = info_section.Main
if main == nil then
if main == nil then
return ''
return {}
end
end
local frame = mw.getCurrentFrame()
local frame = mw.getCurrentFrame()
local props = info_section.Main.Props
local props = {}
-- Start with the standard Props array
if main.Props then
for _, prop in ipairs(main.Props) do
table.insert(props, prop)
end
end
local main_boxes = {}
local main_boxes = {}
for k, prop in pairs(props) do
for k, prop in ipairs(props) do -- use ipairs for ordered iteration
local value = prop.Value
local value = prop.Value
local scale = pick_scale(prop)
-- If it scales with melee damage, set the value using the hero's base melee to match in game visuals
-- If it scales with melee damage, set the value using the hero's base melee to match in game visuals
if prop.Scale and prop.Scale.Type == 'melee' then
if scale and scale.Type == 'melee' then
local hero = get_hero_data(hero_key)
local hero = get_hero_data(hero_key)
value = prop.Value + hero.LightMeleeDamage * prop.Scale.Value
value = prop.Value + hero.LightMeleeDamage * scale.Value
elseif scale and scale.Type == 'heavy_melee' then
local hero = get_hero_data(hero_key)
value = prop.Value + hero.HeavyMeleeDamage * scale.Value
elseif scale and scale.Type == 'weapon_power' then
local hero = get_hero_data(hero_key)
value = hero.Weapon.BulletDamage * hero.Weapon.BulletsPerBurst * (scale.Value + 100) / 100
end
end
-- Exclude 0 values
local icon = Icon.getAttrIcon(prop.Type)
if value and value ~= 0 then
local icon = get_icon(prop.Type)
local main_box
local main_box = frame:expandTemplate{
if prop.StatusEffect then
main_box = frame:expandTemplate{
title = "Ability_card_v2/Card/StatusBox",
args = {
title = prop.Title,
key = prop.Key,
value = value,
status_effect = prop.StatusEffect,
icon = icon and icon.img,
icon_link = icon and icon.link,
icon_color = icon and icon.color,
icon_size = icon and icon.size,
scale_value = scale and commonutils.round_to_sig_fig(scale.Value, 3),
scale_type =  scale and scale.Type,
-- allow nil value here as some status effects don't have a value but should not be hidden
hide = value == 0 and "true" or "false",
}
}
else
main_box = frame:expandTemplate{
title = "Ability_card_v2/Card/MainBox",
title = "Ability_card_v2/Card/MainBox",
args = {
args = {
Line 296: Line 375:
key = prop.Key,
key = prop.Key,
value = value,
value = value,
icon = icon.img,
icon = icon and icon.img,
icon_link = icon.link,
icon_link = icon and icon.link,
icon_color = icon.color,
icon_color = icon and icon.color,
icon_size = icon.size,
icon_size = icon and icon.size,
scale_value = prop.Scale and commonutils.round_to_sig_fig(prop.Scale.Value, 3),
scale_value = scale and commonutils.round_to_sig_fig(scale.Value, 3),
scale_type =  prop.Scale and prop.Scale.Type
scale_type =  scale and scale.Type,
hide = (value == nil or value == 0) and "true" or "false",
}
}
}
}
table.insert(main_boxes, main_box)
end
end
table.insert(main_boxes, main_box)
end
end
Line 336: Line 416:
local alt_boxes = {}
local alt_boxes = {}
for k, prop in pairs(props) do
    for k, prop in ipairs(props) do
-- Some props don't have values, as those come from upgrades
    -- Some props don't have values, as those come from upgrades
-- For now, we will ignore these and only show data for the base ability
    -- For now, we will ignore these and only show data for the base ability
if prop.Value and prop.Value ~= 0 then
    if prop.Value then
local icon = get_icon(prop.Type)
    local icon = Icon.getAttrIcon(prop.Type)
local alt_box = frame:expandTemplate{
 
title = "Ability_card_v2/Card/AltBox",
    local value = prop.Value
args = {
    local scale = pick_scale(prop)
  key = prop.Key,
 
  value = prop.Value,
    if scale and scale.Type == 'weapon_power' then
  icon = icon.img,
    local hero = get_hero_data(hero_key)
  icon_link = icon.link,
    value = hero.Weapon.BulletDamage * hero.Weapon.BulletsPerBurst * (scale.Value + 100) / 100
  icon_color = icon.color,
    end
    icon_size = icon.size,
 
  scale_value = prop.Scale and commonutils.round_to_sig_fig(prop.Scale.Value, 3),
    local alt_box = frame:expandTemplate{
  scale_type = prop.Scale and prop.Scale.Type
    title = "Ability_card_v2/Card/AltBox",
}
    args = {
}
    key = prop.Key,
table.insert(alt_boxes, alt_box)
    value = value,
end
    icon = icon and icon.img,
end
    icon_link = icon and icon.link,
    icon_color = icon and icon.color,
    icon_size = icon and icon.size,
    scale_value = scale and commonutils.round_to_sig_fig(scale.Value, 3),
    scale_type = scale and scale.Type,
    hide = value == 0 and "true" or "false",
    }
    }
    table.insert(alt_boxes, alt_box)
    end
    end
return alt_boxes
return alt_boxes
Line 362: Line 452:


local UPGRADE_COST_MAP = {1, 2, 5}
local UPGRADE_COST_MAP = {1, 2, 5}
-- Upgrade description sizing. These are design proportions, not copies of any
-- CSS measurement: the browser supplies the box width through the container
-- query on .ac-upgrade-descwrap, so nothing here needs updating if the card's
-- widths, margins or padding change.
local UG_DESC_ASPECT = 0.357  -- max height of the text block, as a multiple of its width
local UG_LINE_HEIGHT = 1.4    -- emitted alongside the size so the two cannot drift
local UG_MAX_CQW    = 11.9  -- ceiling, as % of the text width (~16px on a full-width card)
local UG_MIN_CQW    = 6.7    -- floor (~9px)
function get_upgrade_boxes(hero_key, ability_num)
function get_upgrade_boxes(hero_key, ability_num)
local ability = utils.get_ability_card_data(hero_key, ability_num)
local ability = utils.get_ability_card_data(hero_key, ability_num)
Line 368: Line 468:
local frame = mw.getCurrentFrame()
local frame = mw.getCurrentFrame()


local props = ability.Upgrades
local upgrades = ability.Upgrades
local upgrade_boxes = {}
local upgrade_boxes = {}
for k, prop in pairs(props) do
for k, upgrade in ipairs(upgrades) do
local description = lang.get_string(prop.DescKey)
local description = lang.get_string(upgrade.DescKey)
 
-- bypass some keys if they are mistakenly left in game files
local IGNORE_DESC_KEYS = {'citadel_ability_hornet_snipe_t3_desc', 'ability_fencer_ultimate_t3_desc'}
-- Generate a description if there is no localized string
-- Generate a description if there is no localized string
if (description == nil or description == '') then
if (description == nil or description == '' or commonutils.contains(IGNORE_DESC_KEYS, upgrade.DescKey)) then
description = create_description(prop, frame)
description = create_description(upgrade, frame)
else
-- Replace embedded value placeholders with just the base value
-- Pattern: {'Value': X, 'Scale': {...}} -> X
description = description:gsub("{'Value':%s*([%d%.%-]+),%s*'Scale':%s*{[^}]+}}", function(value)
return commonutils.round_to_sig_fig(tonumber(value), 3)
end)
-- Pattern: {'Value': X} -> X
description = description:gsub("{'Value':%s*([%d%.%-]+)}", function(value)
return commonutils.round_to_sig_fig(tonumber(value), 3)
end)
end
end
-- Vary the font size based on the number of characters to prevent overflow
-- Expand once, then size from the text the reader actually sees, rather
local fontsize = '1rem'
-- than from a character count of the unexpanded string.
if #description > 60 and #description < 71 then
local rendered = frame:preprocess(description)
fontsize = '0.95rem'
local cqw = textfit.fit(rendered, {
elseif #description > 70 and #description < 91 then
aspect = UG_DESC_ASPECT,
fontsize = '0.875rem'
lineHeight = UG_LINE_HEIGHT,
elseif #description > 90 then
max = UG_MAX_CQW,
fontsize = '0.8rem'
min = UG_MIN_CQW,
end
})
local upgrade_scale = pick_scale(upgrade)
local upgrade_box = frame:expandTemplate{
local upgrade_box = frame:expandTemplate{
title = "Ability_card_v2/Card/UpgradeBox",
title = "Ability_card_v2/Card/UpgradeBox",
args = {
args = {
  cost = UPGRADE_COST_MAP[k],
index = k,
  description = frame:preprocess(description),
cost = UPGRADE_COST_MAP[k],
  scale_value = prop.Scale and commonutils.round_to_sig_fig(prop.Scale.Value, 3),
description = rendered,
  scale_type =  prop.Scale and prop.Scale.Type,
scale_value = upgrade_scale and commonutils.round_to_sig_fig(upgrade_scale.Value, 3),
  fontsize = fontsize
scale_type =  upgrade_scale and upgrade_scale.Type,
-- Measured against the box's own width, so the size stays correct
-- when the card renders below its 500px max-width.
fontsize = string.format('%.2fcqw', cqw),
lineheight = UG_LINE_HEIGHT
}
}
}
}
Line 404: Line 522:
return upgrade_boxes
return upgrade_boxes
end
end


function create_description(prop, frame)
function create_description(prop, frame)
     local description = ''
     local value_lines = {}  -- normal stat changes
     local first = true
     local scale_lines = {}  -- "Increased X scaling" notes
     local seen = {} -- Set to track added combinations
     local seen = {}


     for k, v in pairs(prop) do
     for k, v in pairs(prop) do
   
        if k == 'DescKey' then
         if type(v) ~= 'table' then
            -- skip
         elseif type(v) == 'table' then
            local has_value = v.Value ~= nil
            local has_scale = v.Scale and v.Scale.Value and v.Scale.Value ~= 0
 
            -- Value is 0 and we have a scaling change → scaling note
            if has_value and has_scale and v.Value == 0 then
                if not seen["scale_" .. k] then
                    local scale_type = v.Scale.Type
                    local capitalized = scale_type:sub(1,1):upper() .. scale_type:sub(2)
                    table.insert(scale_lines, "Increased " .. capitalized .. " scaling")
                    seen["scale_" .. k] = true
                end
            -- Only a scaling change, no value → scaling note (future bot correction)
            elseif not has_value and has_scale then
                if not seen["scale_" .. k] then
                    local scale_type = v.Scale.Type
                    local capitalized = scale_type:sub(1,1):upper() .. scale_type:sub(2)
                    table.insert(scale_lines, "Increased " .. capitalized .. " scaling")
                    seen["scale_" .. k] = true
                end
            -- Normal value (0 without scale, or non‑zero with/without scale)
            elseif has_value then
                local formatted_value = utils.format_value_with_prepost(k, v.Value, frame)
                local attr_name = lang.get_string(k .. '_label')
                local key = formatted_value .. '|' .. attr_name
                if not seen[key] then
                    table.insert(value_lines, string.format('%s %s', formatted_value, attr_name))
                    seen[key] = true
                end
            end
        else
            -- Plain number/string (e.g. OutgoingDamagePenaltyPercent = -15)
             local formatted_value = utils.format_value_with_prepost(k, v, frame)
             local formatted_value = utils.format_value_with_prepost(k, v, frame)
             local attr_name = lang.get_string(k..'_label')
             local attr_name = lang.get_string(k .. '_label')
             local key = formatted_value .. '|' .. attr_name -- Unique identifier
             local key = formatted_value .. '|' .. attr_name
 
            -- Skip if this combination has already been added
             if not seen[key] then
             if not seen[key] then
            -- Add line break if this is not the first item
                 table.insert(value_lines, string.format('%s %s', formatted_value, attr_name))
                 if not first then
                seen[key] = true
                  description = description .. '<br>'
                end
 
            description = description .. string.format('%s %s', formatted_value, attr_name)
            first = false
            seen[key] = true -- Mark this combination as added
           
             end
             end
         end
         end
     end
     end


     return description
     -- Combine: value lines first, then scaling lines
end
    local all_lines = {}
    for _, line in ipairs(value_lines) do
        table.insert(all_lines, line)
    end
    for _, line in ipairs(scale_lines) do
        table.insert(all_lines, line)
    end


p.get_attr_icon = function(frame)
    return table.concat(all_lines, '<br>')
local attr_type = frame.args[1]
local icon = get_icon(attr_type)
 
return string.format('[[File:%s|%s|18px|link=%s]]', icon.img or '', icon.size or '', icon.link or '')
end
 
function get_icon(attr_type)
local mappedAttr = ATTR_TYPE_ICON_MAP[attr_type]
local img = 'GenericProperty.png'
local link = ''
local size = ''
local color = 'Grey'
if mappedAttr then
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


Line 532: Line 660:
return mw.getCurrentFrame():expandTemplate{
return mw.getCurrentFrame():expandTemplate{
title = "Template:Ability card v2/Card",
title = "Ability card v2/Card",
args = {
args = {
hero_key = hero_key,
hero_key = hero_key,
Line 540: Line 668:
icon = lang.get_string(ability.Key, 'en') .. '.png',
icon = lang.get_string(ability.Key, 'en') .. '.png',
description = mw.getCurrentFrame():preprocess(lang.get_string(ability.DescKey)),
description = mw.getCurrentFrame():preprocess(lang.get_string(ability.DescKey)),
channel_time = ability.AbilityChannelTime and ability.AbilityChannelTime.Value ~= 9999 and ability.AbilityChannelTime.Value,
channel_time_scale = get_attr_scale(ability.AbilityChannelTime),
channel_time_scale_type = get_attr_scale_type(ability.AbilityChannelTime),
channel_time_scale_multiply = get_attr_scale_multiply(ability.AbilityChannelTime),
radius = ability.Radius and ability.Radius.Value,
radius = ability.Radius and ability.Radius.Value,
radius_ss = get_attr_ss(ability.Radius),
radius_scale = get_attr_scale(ability.Radius),
radius_scale_type = get_attr_scale_type(ability.Radius),
radius_scale_multiply = get_attr_scale_multiply(ability.Radius),
range = ability.AbilityCastRange and ability.AbilityCastRange.Value,
range = ability.AbilityCastRange and ability.AbilityCastRange.Value,
range_ss = get_attr_ss(ability.AbilityCastRange),
range_scale = get_attr_scale(ability.AbilityCastRange),
range_scale_type = get_attr_scale_type(ability.AbilityCastRange),
range_scale_multiply = get_attr_scale_multiply(ability.AbilityCastRange),
duration = ability.AbilityDuration and ability.AbilityDuration.Value,
duration = ability.AbilityDuration and ability.AbilityDuration.Value,
duration_ss = get_attr_ss(ability.AbilityDuration),
duration_scale = get_attr_scale(ability.AbilityDuration),
duration_scale_type = get_attr_scale_type(ability.AbilityDuration),
duration_scale_multiply = get_attr_scale_multiply(ability.AbilityDuration),
-- ability_width = format_value_with_prepost(width_key, ability[width_key]),
-- ability_width = format_value_with_prepost(width_key, ability[width_key]),
cooldown =ability.AbilityCooldown and ability.AbilityCooldown.Value,
cooldown =ability.AbilityCooldown and ability.AbilityCooldown.Value,
cooldown_ss = get_attr_ss(ability.AbilityCooldown),
cooldown_scale = get_attr_scale(ability.AbilityCooldown),
charge_cooldown = ability.AbilityCooldownBetweenCharge and ability.AbilityCooldownBetweenCharge.Value,
cooldown_scale_type = get_attr_scale_type(ability.AbilityCooldown),
charge_cooldown_ss = get_attr_ss(ability.AbilityCooldownBetweenCharge),
cooldown_scale_multiply = get_attr_scale_multiply(ability.AbilityCooldown),
num_of_charges = ability.AbilityCharges and ability.AbilityCharges.Value,
charge_cooldown = ability.AbilityCooldownBetweenCharge and ability.AbilityCooldownBetweenCharge.Value ~= -1 and ability.AbilityCooldownBetweenCharge.Value,
notes = notes,
charge_cooldown_scale = get_attr_scale(ability.AbilityCooldownBetweenCharge),
notes_source_page = notes_source_page
charge_cooldown_scale_type = get_attr_scale_type(ability.AbilityCooldownBetweenCharge),
charge_cooldown_scale_multiply = get_attr_scale_multiply(ability.AbilityCooldownBetweenCharge),
num_of_charges = ability.AbilityCharges and ability.AbilityCharges.Value or 0,
}
}
}
}

Latest revision as of 12:59, 31 July 2026

Overview

[edit source]

Generates hero ability cards

Template:Ability card v2

Submodules

[edit source]

Abilities - Simple functions. Eg. getting ability name

Abilities/utils - Common internal functions that are shared amongst any Abilities/ modules

Abilities/card - Generates hero ability cards

Abilities/icon - Functions for retrieving ability and attribute icon data

Abilities/details table (WIP) - Generates details table to show raw ability data


local lang = require "Module:Lang"
local commonutils = require "Module:Utilities"
local utils = require "Module:Abilities/utils"
local Icon = require"Module:Abilities/icon"
local textfit = require "Module:TextFit"

local p = {}
local data = mw.loadJsonData("Data:AbilityCards.json")


function get_hero_key(hero_name)
	if hero_name == nil then return nil end
		
	-- if name starts with "hero_", use it directly as the key
	if string.sub(hero_name, 1, 5) == "hero_" then return hero_name end
	
	local resource_lookup = mw.loadJsonData("Data:ResourceLookup.json")
	return resource_lookup[hero_name:lower()].key
end

--{{#invoke:Abilities/card|get_ability_card|HERO_NAME|ABILITY_NUM|ADD_LINK|NOTES}}--
-- Args:
--   HERO_NAME (required) - Name of the hero that is found in Data:HeroData.json under "Name"
--   ABILITY_NUM (required) - Selects ability at index 1 to 4
--   ADD_LINK - Add a hyperlink to the title to the associated page
--   NOTES - User note data to include in the footer of the ability card
p.get_ability_card = function(frame)
	local hero_name = frame.args[1]
	local ability_num = frame.args[2]
	local add_link = frame.args[3]
	local notes = frame.args[4]
	
	local hero_key = get_hero_key(hero_name)
	if (hero_key == nil) then return 'Hero with name' .. hero_name .. 'not found' end
	
	return build_ability_card(hero_key, ability_num, add_link, notes)
end

-- Pulls data from Data:AbilityCards.json to populate Template:Ability card v2
function build_ability_card(hero_key, ability_num, add_link, notes, notes_source_page)
	local ability = utils.get_ability_card_data(hero_key, ability_num)
	if(ability == nil) then 
		return 'Ability data not found for hero ' ..hero_key.. ' and num ' .. ability_num
	end
	
	local ability_name_localized = lang.get_string(ability.Key)
	local name_link = nil
	if add_link == 'true' then
		name_link = ability_name_localized
	end
	
	if notes_source_page ~= nil and notes ~= "" then
		-- Notes comes from a /Notes page, and the notes are not blank
		-- Confirm the notes source page exists, otherwise, don't display any notes
		local title = mw.title.new(notes_source_page)
		if not (title and title.exists) then
			notes = ""
		end
	end
	
	local frame = mw.getCurrentFrame()
	
	local info1_desc = get_info_desc(hero_key, ability_num, 1)
	local info1_main_boxes = get_main_boxes(hero_key, ability_num, 1)

	local info1_alt_boxes = get_alt_boxes(hero_key, ability_num, 1)
	
	if #info1_alt_boxes > 7 then
		error(#info1_alt_boxes .. ' alt boxes found, but only 7 are supported. Please update Module:Abilities/card and Template:Ability_card_v2')	
	end
	
	local info2_desc = get_info_desc(hero_key, ability_num, 2)
	local info2_main_boxes = get_main_boxes(hero_key, ability_num, 2)
	local info2_alt_boxes = get_alt_boxes(hero_key, ability_num, 2)
	
	if #info2_alt_boxes > 7 then
		error(#info2_alt_boxes .. ' alt boxes found, but only 7 are supported. Please update Module:Abilities/card and Template:Ability_card_v2')	
	end
	
	local info3_desc = get_info_desc(hero_key, ability_num, 3)
	local info3_main_boxes = get_main_boxes(hero_key, ability_num, 3)
	local info3_alt_boxes = get_alt_boxes(hero_key, ability_num, 3)
	
	if #info3_alt_boxes > 7 then
		error(#info3_alt_boxes .. ' alt boxes found, but only 7 are supported. Please update Module:Abilities/card and Template:Ability_card_v2')	
	end
			
	local upgrades = get_upgrade_boxes(hero_key, ability_num)
	
	return frame:expandTemplate{
		title = "Ability_card_v2/Card",
		args = {
			id = hero_key .. ':' .. ability.Key,
			hero_key = hero_key,
			ability_num = ability_num,
			
			-- Header info defined in various attributes	
			name = ability_name_localized,
			name_link = name_link,
			icon = lang.get_string(ability.Key, 'en') .. '.png',
			
			channel_time = ability.AbilityChannelTime and ability.AbilityChannelTime.Value ~= 9999 and ability.AbilityChannelTime.Value,
			channel_time_scale = get_attr_scale(ability.AbilityChannelTime),
			channel_time_scale_type = get_attr_scale_type(ability.AbilityChannelTime),
			channel_time_scale_multiply = get_attr_scale_multiply(ability.AbilityChannelTime),
			radius = ability.Radius and ability.Radius.Value,
			radius_scale = get_attr_scale(ability.Radius),
			radius_scale_type = get_attr_scale_type(ability.Radius),
			radius_scale_multiply = get_attr_scale_multiply(ability.Radius),
			range = ability.AbilityCastRange and ability.AbilityCastRange.Value,
			range_scale = get_attr_scale(ability.AbilityCastRange),
			range_scale_type = get_attr_scale_type(ability.AbilityCastRange),
			range_scale_multiply = get_attr_scale_multiply(ability.AbilityCastRange),
			duration = ability.AbilityDuration and ability.AbilityDuration.Value,
			duration_scale = get_attr_scale(ability.AbilityDuration),
			duration_scale_type = get_attr_scale_type(ability.AbilityDuration),
			duration_scale_multiply = get_attr_scale_multiply(ability.AbilityDuration),
			-- ability_width = format_value_with_prepost(width_key, ability[width_key]),
			cooldown =ability.AbilityCooldown and ability.AbilityCooldown.Value,
			cooldown_scale = get_attr_scale(ability.AbilityCooldown),
			cooldown_scale_type = get_attr_scale_type(ability.AbilityCooldown),
			cooldown_scale_multiply = get_attr_scale_multiply(ability.AbilityCooldown),
			charge_cooldown = ability.AbilityCooldownBetweenCharge and ability.AbilityCooldownBetweenCharge.Value ~= -1 and ability.AbilityCooldownBetweenCharge.Value,
			charge_cooldown_scale = get_attr_scale(ability.AbilityCooldownBetweenCharge),
			charge_cooldown_scale_type = get_attr_scale_type(ability.AbilityCooldownBetweenCharge),
			charge_cooldown_scale_multiply = get_attr_scale_multiply(ability.AbilityCooldownBetweenCharge),
			num_of_charges = ability.AbilityCharges and ability.AbilityCharges.Value or 0,
			
			-- Info section #1 defined in "Info1" attribute
			hide_info1 = infobox_is_hidden(ability.Info1) and "true" or "false",
			info1_desc = info1_desc,
			info1_mainbox1 = info1_main_boxes[1],
			info1_mainbox2 = info1_main_boxes[2],
			info1_mainbox3 = info1_main_boxes[3],
			info1_mainbox4 = info1_main_boxes[4],
			info1_mainbox5 = info1_main_boxes[5],
			info1_altbox1 =  info1_alt_boxes[1],
			info1_altbox2 =  info1_alt_boxes[2],
			info1_altbox3 =  info1_alt_boxes[3],
			info1_altbox4 =  info1_alt_boxes[4],
			info1_altbox5 =  info1_alt_boxes[5],
			info1_altbox6 =  info1_alt_boxes[6],
			info1_altbox7 =  info1_alt_boxes[7],

			-- Info section #2 defined in "Info2" attribute
			hide_info2 = infobox_is_hidden(ability.Info2) and "true" or "false",
			info2_desc = info2_desc,
			info2_mainbox1 = info2_main_boxes[1],
			info2_mainbox2 = info2_main_boxes[2],
			info2_mainbox3 = info2_main_boxes[3],
			info2_mainbox4 = info2_main_boxes[4],
			info2_mainbox5 = info2_main_boxes[5],
			info2_altbox1 =  info2_alt_boxes[1],
			info2_altbox2 =  info2_alt_boxes[2],
			info2_altbox3 =  info2_alt_boxes[3],
			info2_altbox4 =  info2_alt_boxes[4],
			info2_altbox5 =  info2_alt_boxes[5],
			info2_altbox6 =  info2_alt_boxes[6],
			info2_altbox7 =  info2_alt_boxes[7],

			-- Info section #3 defined in "Info3" attribute
			hide_info3 = infobox_is_hidden(ability.Info3) and "true" or "false",
			info3_desc = info3_desc,
			info3_mainbox1 = info3_main_boxes[1],
			info3_mainbox2 = info3_main_boxes[2],
			info3_mainbox3 = info3_main_boxes[3],
			info3_mainbox4 = info3_main_boxes[4],
			info3_mainbox5 = info3_main_boxes[5],
			info3_altbox1 =  info3_alt_boxes[1],
			info3_altbox2 =  info3_alt_boxes[2],
			info3_altbox3 =  info3_alt_boxes[3],
			info3_altbox4 =  info3_alt_boxes[4],
			info3_altbox5 =  info3_alt_boxes[5],
			info3_altbox6 =  info3_alt_boxes[6],
			info3_altbox7 =  info3_alt_boxes[7],

			-- Ability upgrades defined in "Upgrades" attribute
			upgrade1 = upgrades[1],
			upgrade2 = upgrades[2],
			upgrade3 = upgrades[3],
			
			-- User-created ability notes
			notes = notes,
			notes_source_page = notes_source_page
		}
	}
end

function infobox_is_hidden(infobox)
	if infobox == nil then 
		return false
	end
	
	return infobox.RequiresUpgradeIndex ~= nil
end

-- An attribute may carry either a single scaling or a list of them.
-- Extra entries are stat-category markers (duration, cooldown, range, radius,
-- healing, charges) that always hold a value of 1 and are never displayed, so
-- return the first entry that actually renders a badge.
-- A zero-valued scaling is still returned when nothing else is displayable. The
-- card renders it as an x0 placeholder, and the ability upgrade gadget fills
-- that element in when an upgrade grants real scaling (Doorman's Doorway).
local function pick_scale(attr)
	if not attr then
		return nil
	end
	
	local scale = attr.Scale
	if type(scale) ~= 'table' then
		return nil
	end
	
	-- A single scaling is stored directly as an object
	if scale[1] == nil then
		return scale
	end
	
	local placeholder = nil
	for _, entry in ipairs(scale) do
		if type(entry) == 'table' and Icon.isDisplayedScale(entry.Type) then
			if entry.Value ~= 0 then
				return entry
			end
			placeholder = placeholder or entry
		end
	end
	
	return placeholder
end

-- Get scaling of attribute, return nil if none is found
function get_attr_scale(attr)
	local scale = pick_scale(attr)
	if not scale then
		return nil
	end
	
	if scale.Value == 0 then
		return nil	
	end
	
	return commonutils.round_to_sig_fig(scale.Value, 3)
end

-- Get scaling type of attribute, return nil if none is found
function get_attr_scale_type(attr)
	local scale = pick_scale(attr)
	if not scale then
		return nil
	end
	
	if scale.Value == 0 then
		return nil	
	end
	
	return scale.Type
end

-- Check if scaling should be multiplied instead of added
function get_attr_scale_multiply(attr)
	local scale = pick_scale(attr)
	if not scale then
		return nil
	end
	
	return scale.Multiply
end

-- Get the description for an ability's info section
--{{#invoke:AbilityData|get_info_desc|HERO_KEY|ABILITY_NUM|INFO_SECTION_INDEX}}--
p.get_info_desc = function(frame)
	local hero_key = frame.args[1]
	local ability_num = frame.args[2]
	local info_section_num = frame.args[3]
	
	local ability = utils.get_ability_card_data(hero_key, ability_num)
	if(ability == nil) then return {} end -- return empty table, not string
	local info_section = ability['Info'..info_section_num]
	
	-- some abilities have no info sections
	if info_section == nil then return '' end
	
	if info_section.DescKey == nil then
		return ''	
	end
	
	return frame:preprocess(lang.get_string(info_section.DescKey))
end

function get_info_desc(hero_key, ability_num, info_section_num)
	local ability = utils.get_ability_card_data(hero_key, ability_num)
	if(ability == nil) then return "Ability Not Found" end
	local info_section = ability['Info'..info_section_num]
	
	-- some abilities have no info sections
	if info_section == nil then return '' end
	
	if info_section.DescKey == nil then
		return ''	
	end
	
	local frame = mw.getCurrentFrame()
	return frame:preprocess(lang.get_string(info_section.DescKey))
end

function get_main_boxes(hero_key, ability_num, info_section_num)
	local ability = utils.get_ability_card_data(hero_key, ability_num)
	if(ability == nil) then return "Ability Not Found" end
	
	local info_section = ability['Info'..info_section_num]
	
	-- some abilities have no info sections
	if info_section == nil then return '' end
	
	local main = info_section.Main
	if main == nil then
		return {}
	end
	
	local frame = mw.getCurrentFrame()
	
	local props = {}
	
	-- Start with the standard Props array
	if main.Props then
		for _, prop in ipairs(main.Props) do
			table.insert(props, prop)
		end
	end
	
	
	local main_boxes = {}
	for k, prop in ipairs(props) do  -- use ipairs for ordered iteration
		local value = prop.Value
		local scale = pick_scale(prop)
		-- If it scales with melee damage, set the value using the hero's base melee to match in game visuals
		if scale and scale.Type == 'melee' then
			local hero = get_hero_data(hero_key)
			value = prop.Value + hero.LightMeleeDamage * scale.Value
			elseif scale and scale.Type == 'heavy_melee' then
			local hero = get_hero_data(hero_key)
			value = prop.Value + hero.HeavyMeleeDamage * scale.Value
			elseif scale and scale.Type == 'weapon_power' then
			local hero = get_hero_data(hero_key)
			value = hero.Weapon.BulletDamage * hero.Weapon.BulletsPerBurst * (scale.Value + 100) / 100
		end
			
		local icon = Icon.getAttrIcon(prop.Type)
		
		local main_box
		if prop.StatusEffect then
			main_box = frame:expandTemplate{
				title = "Ability_card_v2/Card/StatusBox",
				args = {
					title = prop.Title,
					key = prop.Key,
					value = value,
					status_effect = prop.StatusEffect,
					icon = icon and icon.img,
					icon_link = icon and icon.link,
					icon_color = icon and icon.color,
					icon_size = icon and icon.size,
					scale_value = scale and commonutils.round_to_sig_fig(scale.Value, 3),
					scale_type =  scale and scale.Type,
					-- allow nil value here as some status effects don't have a value but should not be hidden
					hide = value == 0 and "true" or "false",
				}
			}
		else 
			main_box = frame:expandTemplate{
				title = "Ability_card_v2/Card/MainBox",
				args = {
					title = prop.Title,
					key = prop.Key,
					value = value,
					icon = icon and icon.img,
					icon_link = icon and icon.link,
					icon_color = icon and icon.color,
					icon_size = icon and icon.size,
					scale_value = scale and commonutils.round_to_sig_fig(scale.Value, 3),
					scale_type =  scale and scale.Type,
					hide = (value == nil or value == 0) and "true" or "false",
				}
			}
		end
		table.insert(main_boxes, main_box)
	end
	
	return main_boxes
end

function get_hero_data(hero_key)
	local hero_data = mw.loadJsonData("Data:HeroData.json")
	local hero = hero_data[hero_key]
	if (hero == nil) then return "Hero Not Found" end
	return hero
end

function get_alt_boxes(hero_key, ability_num, info_section_num)
	local ability = utils.get_ability_card_data(hero_key, ability_num)
	if(ability == nil) then return "Ability Not Found" end
	
	local info_section = ability['Info'..info_section_num]
	
	-- some abilities have no info sections
	if info_section == nil then return '' end
	
	local props = info_section.Alt
	if props == nil then
		return ''
	end
	
	local frame = mw.getCurrentFrame()
	
	local alt_boxes = {}
	
    for k, prop in ipairs(props) do
    	-- Some props don't have values, as those come from upgrades
    	-- For now, we will ignore these and only show data for the base ability
    	if prop.Value then
    		local icon = Icon.getAttrIcon(prop.Type)

    		local value = prop.Value
    		local scale = pick_scale(prop)

    		if scale and scale.Type == 'weapon_power' then
    			local hero = get_hero_data(hero_key)
    			value = hero.Weapon.BulletDamage * hero.Weapon.BulletsPerBurst * (scale.Value + 100) / 100
    		end

    		local alt_box = frame:expandTemplate{
    			title = "Ability_card_v2/Card/AltBox",
    			args = {
    				key = prop.Key,
    				value = value,
    				icon = icon and icon.img,
    				icon_link = icon and icon.link,
    				icon_color = icon and icon.color,
    				icon_size = icon and icon.size,
    				scale_value = scale and commonutils.round_to_sig_fig(scale.Value, 3),
    				scale_type = scale and scale.Type,
    				hide = value == 0 and "true" or "false",
    			}
    		}
    		table.insert(alt_boxes, alt_box)
    	end
    end
	
	return alt_boxes
end

local UPGRADE_COST_MAP = {1, 2, 5}

-- Upgrade description sizing. These are design proportions, not copies of any
-- CSS measurement: the browser supplies the box width through the container
-- query on .ac-upgrade-descwrap, so nothing here needs updating if the card's
-- widths, margins or padding change.
local UG_DESC_ASPECT = 0.357  -- max height of the text block, as a multiple of its width
local UG_LINE_HEIGHT = 1.4    -- emitted alongside the size so the two cannot drift
local UG_MAX_CQW     = 11.9   -- ceiling, as % of the text width (~16px on a full-width card)
local UG_MIN_CQW     = 6.7    -- floor (~9px)

function get_upgrade_boxes(hero_key, ability_num)
	local ability = utils.get_ability_card_data(hero_key, ability_num)
	if(ability == nil) then return "Ability Not Found" end
	
	local frame = mw.getCurrentFrame()

	local upgrades = ability.Upgrades
	
	local upgrade_boxes = {}
	for k, upgrade in ipairs(upgrades) do
		local description = lang.get_string(upgrade.DescKey)

		-- bypass some keys if they are mistakenly left in game files
		local IGNORE_DESC_KEYS = {'citadel_ability_hornet_snipe_t3_desc', 'ability_fencer_ultimate_t3_desc'}
		
		-- Generate a description if there is no localized string
		if (description == nil or description == '' or commonutils.contains(IGNORE_DESC_KEYS, upgrade.DescKey)) then
			description = create_description(upgrade, frame)
		else
			-- Replace embedded value placeholders with just the base value
			-- Pattern: {'Value': X, 'Scale': {...}} -> X
			description = description:gsub("{'Value':%s*([%d%.%-]+),%s*'Scale':%s*{[^}]+}}", function(value)
				return commonutils.round_to_sig_fig(tonumber(value), 3)
			end)
			-- Pattern: {'Value': X} -> X
			description = description:gsub("{'Value':%s*([%d%.%-]+)}", function(value)
				return commonutils.round_to_sig_fig(tonumber(value), 3)
			end)
		end
		
		-- Expand once, then size from the text the reader actually sees, rather
		-- than from a character count of the unexpanded string.
		local rendered = frame:preprocess(description)
		local cqw = textfit.fit(rendered, {
			aspect = UG_DESC_ASPECT,
			lineHeight = UG_LINE_HEIGHT,
			max = UG_MAX_CQW,
			min = UG_MIN_CQW,
		})
		
		local upgrade_scale = pick_scale(upgrade)
		local upgrade_box = frame:expandTemplate{
			title = "Ability_card_v2/Card/UpgradeBox",
			args = {
				index = k,
				cost = UPGRADE_COST_MAP[k],	
				description = rendered,
				scale_value = upgrade_scale and commonutils.round_to_sig_fig(upgrade_scale.Value, 3),
				scale_type =  upgrade_scale and upgrade_scale.Type,
				-- Measured against the box's own width, so the size stays correct
				-- when the card renders below its 500px max-width.
				fontsize = string.format('%.2fcqw', cqw),
				lineheight = UG_LINE_HEIGHT
			}
		}
		table.insert(upgrade_boxes, upgrade_box)
	end
	
	return upgrade_boxes
end


function create_description(prop, frame)
    local value_lines = {}   -- normal stat changes
    local scale_lines = {}   -- "Increased X scaling" notes
    local seen = {}

    for k, v in pairs(prop) do
        if k == 'DescKey' then
            -- skip
        elseif type(v) == 'table' then
            local has_value = v.Value ~= nil
            local has_scale = v.Scale and v.Scale.Value and v.Scale.Value ~= 0

            -- Value is 0 and we have a scaling change → scaling note
            if has_value and has_scale and v.Value == 0 then
                if not seen["scale_" .. k] then
                    local scale_type = v.Scale.Type
                    local capitalized = scale_type:sub(1,1):upper() .. scale_type:sub(2)
                    table.insert(scale_lines, "Increased " .. capitalized .. " scaling")
                    seen["scale_" .. k] = true
                end
            -- Only a scaling change, no value → scaling note (future bot correction)
            elseif not has_value and has_scale then
                if not seen["scale_" .. k] then
                    local scale_type = v.Scale.Type
                    local capitalized = scale_type:sub(1,1):upper() .. scale_type:sub(2)
                    table.insert(scale_lines, "Increased " .. capitalized .. " scaling")
                    seen["scale_" .. k] = true
                end
            -- Normal value (0 without scale, or non‑zero with/without scale)
            elseif has_value then
                local formatted_value = utils.format_value_with_prepost(k, v.Value, frame)
                local attr_name = lang.get_string(k .. '_label')
                local key = formatted_value .. '|' .. attr_name
                if not seen[key] then
                    table.insert(value_lines, string.format('%s %s', formatted_value, attr_name))
                    seen[key] = true
                end
            end
        else
            -- Plain number/string (e.g. OutgoingDamagePenaltyPercent = -15)
            local formatted_value = utils.format_value_with_prepost(k, v, frame)
            local attr_name = lang.get_string(k .. '_label')
            local key = formatted_value .. '|' .. attr_name
            if not seen[key] then
                table.insert(value_lines, string.format('%s %s', formatted_value, attr_name))
                seen[key] = true
            end
        end
    end

    -- Combine: value lines first, then scaling lines
    local all_lines = {}
    for _, line in ipairs(value_lines) do
        table.insert(all_lines, line)
    end
    for _, line in ipairs(scale_lines) do
        table.insert(all_lines, line)
    end

    return table.concat(all_lines, '<br>')
end

function find_width_key(ability)
	for key, value in pairs(ability) do
		if type(key) == "string" and key:sub(-5) == "Width" then
			return key
		end
	end
	return nil
end

--for use from an ability page (ability pages are WIP), not hero pages
function p.write_ability_card_from_ability_key(frame)
	local ability_key = frame.args[1]
	if ability_key == nil then return "ability_key '" .. ability_key "' not provided" end
	
	-- Determine the hero and ability number
	local found_hero_key
	local found_ability_num
	for hero_key, card_data in pairs(data) do
		if found_hero_key == nil or found_ability_num == nil then
			for ability_num, ability_data in pairs(card_data) do
				if ability_num ~= "Name" then
					if ability_data['Key'] == ability_key then 
						found_hero_key = hero_key
						found_ability_num = ability_num
						break 
					end -- hero_key and ability_num found
				end
			end
		end
	end
	
	if found_ability_num == nil or found_hero_key == nil then return "ability_key " .. ability_key .. " is not used by any heroes" end
	
	-- Get notes for this ability
	local notes_source_page_name = utils.get_notes_source_page_name(ability_key)
	local notes_str = frame:preprocess("{{"..notes_source_page_name.."}}")
	
	-- Create the ability card
	return p.get_ability_card_from_key(found_hero_key, found_ability_num, true, notes_str, notes_source_page_name)
end

--from hero key*
--notes parameter will eventually be removed, as notes_source_page would be sufficient or could even be determined from within this function
function p.get_ability_card_from_key(hero_key, ability_num, add_link, notes, notes_source_page)
	if type(hero_key) == 'table' and hero_key.args then
		local frame = hero_key
		hero_key = frame.args[1]
		ability_num = frame.args[2]
		add_link = frame.args[3]
		notes = frame.args[4]
		notes_source_page = frame.args[5]
	end
	
	local ability = utils.get_ability_card_data(hero_key, ability_num)
	if(ability == nil) then 
		return 'Ability data not found for hero ' ..hero_key.. ' and num ' .. ability_num
	end
	
	local ability_name_localized = lang.get_string(ability.Key)
	local name_link = nil
	if add_link == 'true' then
		name_link = ability_name_localized
	end
	
	if notes_source_page ~= nil and notes ~= "" then
		--Notes comes from a /Notes page, and the notes are not blank
		-- Confirm the notes source page exists, otherwise, don't display any notes
		local title = mw.title.new(notes_source_page)
		if not (title and title.exists) then
			notes = ""
		end
	end
	
	return mw.getCurrentFrame():expandTemplate{
		title = "Ability card v2/Card",
		args = {
			hero_key = hero_key,
			ability_num = ability_num,
			name = ability_name_localized,
			name_link = name_link,
			icon = lang.get_string(ability.Key, 'en') .. '.png',
			description = mw.getCurrentFrame():preprocess(lang.get_string(ability.DescKey)),
			channel_time = ability.AbilityChannelTime and ability.AbilityChannelTime.Value ~= 9999 and ability.AbilityChannelTime.Value,
			channel_time_scale = get_attr_scale(ability.AbilityChannelTime),
			channel_time_scale_type = get_attr_scale_type(ability.AbilityChannelTime),
			channel_time_scale_multiply = get_attr_scale_multiply(ability.AbilityChannelTime),
			radius = ability.Radius and ability.Radius.Value,
			radius_scale = get_attr_scale(ability.Radius),
			radius_scale_type = get_attr_scale_type(ability.Radius),
			radius_scale_multiply = get_attr_scale_multiply(ability.Radius),
			range = ability.AbilityCastRange and ability.AbilityCastRange.Value,
			range_scale = get_attr_scale(ability.AbilityCastRange),
			range_scale_type = get_attr_scale_type(ability.AbilityCastRange),
			range_scale_multiply = get_attr_scale_multiply(ability.AbilityCastRange),
			duration = ability.AbilityDuration and ability.AbilityDuration.Value,
			duration_scale = get_attr_scale(ability.AbilityDuration),
			duration_scale_type = get_attr_scale_type(ability.AbilityDuration),
			duration_scale_multiply = get_attr_scale_multiply(ability.AbilityDuration),
			-- ability_width = format_value_with_prepost(width_key, ability[width_key]),
			cooldown =ability.AbilityCooldown and ability.AbilityCooldown.Value,
			cooldown_scale = get_attr_scale(ability.AbilityCooldown),
			cooldown_scale_type = get_attr_scale_type(ability.AbilityCooldown),
			cooldown_scale_multiply = get_attr_scale_multiply(ability.AbilityCooldown),
			charge_cooldown = ability.AbilityCooldownBetweenCharge and ability.AbilityCooldownBetweenCharge.Value ~= -1 and ability.AbilityCooldownBetweenCharge.Value,
			charge_cooldown_scale = get_attr_scale(ability.AbilityCooldownBetweenCharge),
			charge_cooldown_scale_type = get_attr_scale_type(ability.AbilityCooldownBetweenCharge),
			charge_cooldown_scale_multiply = get_attr_scale_multiply(ability.AbilityCooldownBetweenCharge),
			num_of_charges = ability.AbilityCharges and ability.AbilityCharges.Value or 0,
		}
	}	
end

return p