Module:Sandbox/Vergir: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Vergir (talk | contribs)
Parity test for get_hero_scaling_data refactored onto get_scalings (with help from vergir-bot LLM)
Vergir (talk | contribs)
Testing InfoboxHero on shared HeroData helpers (with help from vergir-bot LLM)
Line 1: Line 1:
-- TEMPORARY test module.
--------------------------------------------------------------------------------
-- {{#invoke:Sandbox/Vergir|test}}
-- Module:InfoboxHero
--
-- Renders the hero infobox. Called from [[Template:Infobox hero]], which passes
-- the editor's parameters through as the parent frame:
--
--    {{#invoke:InfoboxHero|render}}
--
-- May also be invoked directly for testing, in which case parameters are read
-- from the invoke itself:
--
--     {{#invoke:InfoboxHero|render|key=hero_atlas}}
--
-- Stat data comes from [[Data:HeroData.json]]; per-hero colors, backgrounds and
-- logo sizes from [[Data:Infobox hero.json]]; labels from [[Module:Lang]] and
-- [[Module:Dictionary]]. Styling lives in [[Template:Infobox hero/styles.css]]
-- and [[Template:Infobox hero/themes.css]].
--
--------------------------------------------------------------------------------
 
local p = {}
local p = {}
local heroes_data = mw.loadJsonData("Data:HeroData.json")
local old_module = require('Module:HeroData')


local function raw_scaling(hero_data, scaling_type, stat)
local hero_data_all = mw.loadJsonData('Data:HeroData.json')
local scaling = hero_data and hero_data[scaling_type .. "Scaling"]
local theme_data    = mw.loadJsonData('Data:Infobox_hero.json')
if (scaling == nil or stat == nil) then return nil end
 
return scaling[stat]
local util        = require('Module:Utilities')
local lang_module = require('Module:Lang')
local dict_module = require('Module:Dictionary')
local hero_module = require('Module:HeroData')
local sb_module  = require('Module:StreetBrawlAbilities')
 
-- Shared hero-data lookups, see [[Module:HeroData]].
local get_stat    = hero_module.get_stat
local get_scalings = hero_module.get_scalings
local has_stat    = hero_module.has_stat
 
local has_override_or_stat, stat_number
 
--------------------------------------------------------------------------------
-- CONFIG
--------------------------------------------------------------------------------
 
local DEFAULT_HERO_KEY = 'hero_targetdummy'  -- used when |key= is missing/unknown
local DEFAULT_SIG_FIGS = 3
local FALLBACK_CARD    = 'Dummy card.png'
 
-- Row fields (all optional except a label):
--  stat        Key in Data:HeroData.json. Resolved top-level, then .Weapon,
--              then .Weapon.AltFire. Omit for manual-input-only rows.
--  param      |param= that overrides the value.
--  label      Key in Data:Lang_<code>.json.
--  label_dict  Key in Data:Dictionary. Used instead of `label`.
--  label_text  Literal, untranslated label. Used instead of `label`.
--  link        Target for the label, as 'Page' or 'Page#Anchor'. The language
--              suffix is inserted for us, so write the plain English title.
--  icon_link  Target for the icon. Defaults to `link`.
--  icon        File: name of the value icon. Omit for no icon.
--  sig_figs    Significant figures. Defaults to 3.
--  suffix      Appended directly after the value, e.g. '%', 's', 'm/s'.
--  scaling    Set false to suppress the Boon/Spirit scaling span. Scaling is
--              shown by default wherever the data provides it.
--  optional    Hide the row unless the hero has a non-zero value or scaling.
--  cell_class  Extra class on the value cell.
--  show        function(ctx) -> boolean. Row omitted when false.
 
local SECTIONS = {
{
class  = 'infobox-h-dropdown-weapon',
header = 'CitadelHeroStats_Weapon',
link  = 'Stats#Weapon',
color  = 'Brown',
rows = {
{
stat    = 'DPS',
param  = 'dps',
label  = 'DPS_label',
link    = 'Damage per second',
icon    = 'Damage.png',
-- DPS is derived from bullet damage and fire rate, both of which
-- show their own scaling; repeating it here would double-count.
scaling = false,
},
{
stat      = 'BulletDamage',
param      = 'bullet_damage',
label      = 'StatDesc_BulletDamage',
link      = 'Bullet Damage',
icon      = 'Bullet damage.png',
cell_class = 'infobox-h-stat-value-padded',
},
{
stat      = 'BulletsPerShot',
param    = 'pellets_per_shot',
label    = 'BonusPelletsPerShot_label',
link      = 'Damage per second',
icon      = 'Bullets per sec icon.png',
icon_link = 'Fire Rate',
show      = function(ctx) return ctx.pellets > 1 end,
},
{
stat       = 'BulletsPerBurst',
param      = 'bullets_per_burst',
label_dict = 'BulletsPerBurst',
link      = 'Damage per second',
icon      = 'Bullets per sec icon.png',
icon_link  = 'Fire Rate',
show      = function(ctx) return ctx.burst > 1 end,
},
{
stat  = 'ClipSize',
param = 'ammo',
label = 'StatDesc_ClipSizeBonus',
link  = 'Ammo',
icon  = 'Clip size icon.png',
},
{
stat  = 'RoundsPerSecond',
param = 'bullets_per_sec',
label = 'StatDesc_RoundsPerSecond',
link  = 'Fire Rate',
icon  = 'Bullets per sec icon.png',
},
{
stat  = 'ReloadTime',
param  = 'reload_time',
label  = 'StatDesc_ReloadTime',
link  = 'Reload Time',
icon  = 'AttributeIconReloadTime.png',
suffix = 's',
},
{
stat  = 'BulletSpeed',
param  = 'bullet_velocity',
label  = 'StatDesc_BulletSpeed',
link  = 'Bullet Velocity',
icon  = 'AttributeIconBulletSpeed.png',
suffix = 'm/s',
},
{
stat      = 'LightMeleeDamage',
param      = 'light_melee',
label      = 'StatDesc_LightMeleeDamage',
link      = 'Melee Damage',
icon      = 'Melee damage.png',
cell_class = 'infobox-h-stat-value-padded',
},
{
stat      = 'HeavyMeleeDamage',
param      = 'heavy_melee',
label      = 'StatDesc_HeavyMeleeDamage',
link      = 'Melee Damage',
icon      = 'Melee damage.png',
cell_class = 'infobox-h-stat-value-padded',
},
{
param      = 'max_range',
label_text = 'Max Range',
link      = 'Falloff Range',
show      = function(ctx) return ctx.args.max_range ~= '' end,
},
{
stat  = 'CritDamageBonusPercent',
label  = 'StatDesc_CritDamageBonusScale',
link  = 'Weapon Damage#Crit_Multiplier',
icon  = 'Crit reduction.png',
suffix = '%',
show  = function(ctx)
return stat_number(ctx, 'CritDamageBonusPercent') ~= 0
end,
},
},
},
{
class  = 'infobox-h-dropdown-vitality',
header = 'CitadelHeroStats_Vitality',
link  = 'Stats#Vitality',
color  = 'Green',
rows = {
{
stat  = 'MaxHealth',
param = 'max_health',
label = 'StatDesc_HealthAttribute',
link  = 'Health',
icon  = 'Health.png',
},
{
stat    = 'BaseHealthRegen',
param    = 'health_regen',
label    = 'StatDesc_HealthRegen',
link    = 'Health Regen',
icon    = 'Health regen.png',
sig_figs = 2,
},
{
stat    = 'CritDamageReceivedPercent',
param    = 'crit_reduction',
label    = 'StatDesc_CritDamageReceivedScale',
link    = 'Damage Resistance#Crit_Reduction',
icon    = 'Crit reduction.png',
suffix  = '%',
optional = true,
},
{
stat      = 'BulletResist',
param    = 'bullet_resist',
label    = 'StatDesc_BulletArmorDamageReduction',
link      = 'Damage Resistance',
icon      = 'Bullet Armor.png',
icon_link = 'Bullet Resist',
suffix    = '%',
optional  = true,
},
{
stat    = 'TechResist',
param    = 'spirit_resist',
label    = 'StatDesc_TechArmorDamageReduction',
link    = 'Damage Resistance#Spirit_Resist',
icon    = 'Spirit Armor.png',
suffix  = '%',
optional = true,
},
{
stat    = 'MeleeResist',
param    = 'melee_resist',
label    = 'StatDesc_MeleeResist',
link    = 'Damage Resistance#Melee Resist',
icon    = 'Melee damage.png',
suffix  = '%',
optional = true,
},
{
stat    = 'DebuffResist',
param    = 'debuff_resist',
label    = 'StatDesc_DebuffResist',
link    = 'Debuff Resist',
icon    = 'Spirit Armor.png',
suffix  = '%',
optional = true,
},
{
stat    = 'BulletLifesteal',
param    = 'bullet_lifesteal',
label    = 'StatDesc_BulletLifesteal',
link    = 'Lifesteal#Bullet Lifesteal',
icon    = 'AttributeIconBulletLifesteal.png',
suffix  = '%',
optional = true,
},
{
stat    = 'SpiritLifesteal',
param    = 'spirit_lifesteal',
label    = 'StatDesc_SpiritLifesteal',
link    = 'Lifesteal#Spirit Lifesteal',
icon    = 'AttributeIconBulletLifesteal.png',
suffix  = '%',
optional = true,
},
{
stat    = 'MaxMoveSpeed',
param    = 'move_speed',
label    = 'StatDesc_RunSpeed',
link    = 'Move Speed',
icon    = 'Move speed.png',
sig_figs = 2,
suffix  = 'm/s',
},
{
stat    = 'SprintSpeed',
param    = 'sprint_speed',
label    = 'StatDesc_SprintSpeed',
link    = 'Move Speed#Sprint',
icon    = 'Move speed sprint.png',
sig_figs = 2,
suffix  = 'm/s',
},
{
stat  = 'GroundDashSpeed',
label  = 'DashSpeed_label',
link  = 'Stamina#Dash_Speed',
icon  = 'Springy boots icon.png',
suffix = 'm/s',
},
{
stat    = 'Stamina',
param    = 'stamina',
label    = 'StatDesc_Stamina',
link    = 'Stamina',
icon    = 'Springy boots icon.png',
sig_figs = 2,
},
{
stat    = 'StaminaCooldown',
param    = 'stamina_cooldown',
label    = 'StatDesc_StaminaCooldown',
link    = 'Stamina#Cooldown',
icon    = 'AttributeIconTechDuration.png',
sig_figs = 2,
suffix  = 's',
},
},
},
{
class  = 'infobox-h-dropdown-spirit',
header = 'CitadelHeroStats_Spirit',
link  = 'Stats#Spirit',
color  = 'Purple',
rows = {
{
stat    = 'TechPower',
param    = 'spirit_power',
label    = 'SpiritPower_label',
link    = 'Spirit Power',
icon    = 'Spirit icon noborder.png',
sig_figs = 2,
},
},
},
}
 
-- The "Information" block. Manual input only; hidden entirely when all are empty.
local INFO_ROWS = {
{ param = 'pronouns',    label_dict = 'Pronouns' },
{ param = 'voice_actor',  label_dict = 'VoiceActor', link = 'Voice actors' },
{ param = 'release_date', label_dict = 'ReleaseDate', link = 'Heroes by release' },
{ param = 'codenames',    label_dict = 'Codenames', tooltip = 'Codenames used for this hero in the game files' },
}
 
-- name: literal, or function(ctx) -> string.  when: function(ctx) -> boolean.
local CATEGORIES = {
{ name = 'Heroes' },
{
name = function(ctx) return ctx.hero_en end,
-- Silver (Transformed) is documented on Silver's page and shares her category.
when = function(ctx) return ctx.hero_en ~= 'Silver (Transformed)' end,
},
{
name = 'Heroes with spreadshot weapons',
when = function(ctx) return ctx.pellets > 1 end,
},
{
name = 'Heroes with single damage spreadshot weapons',
when = function(ctx)
return ctx.pellets > 1 and get_stat(ctx.hero, 'HitOnceAcrossAllBullets') == true
end,
},
{
name = 'Heroes with burst weapons',
when = function(ctx) return ctx.burst > 1 end,
},
{
name = 'Heroes with automatic weapons',
when = function(ctx) return ctx.pellets <= 1 and ctx.burst <= 1 end,
},
{
name = 'Heroes with alternate fire weapons',
when = function(ctx)
return ctx.hero.Weapon ~= nil and ctx.hero.Weapon.AltFire ~= nil
end,
},
{
name = 'Heroes with single reload weapons',
when = function(ctx) return get_stat(ctx.hero, 'ReloadSingle') == true end,
},
{
name = 'Heroes with spin up weapons',
when = function(ctx) return has_stat(ctx.hero, 'SpinAcceleration') end,
},
{
name = 'Heroes with no weapon damage falloff',
when = function(ctx)
return (get_stat(ctx.hero, 'FalloffStartRange') or 0)
    == (get_stat(ctx.hero, 'FalloffEndRange') or 0)
end,
},
{
name = "Heroes that don't deal critical damage",
when = function(ctx) return get_stat(ctx.hero, 'CanCrit') == false end,
},
{
name = 'Heroes with modified crit scale',
when = function(ctx) return stat_number(ctx, 'CritDamageBonusPercent') ~= 0 end,
},
{
name = 'Heroes with modified crit resistance',
when = function(ctx)
return has_override_or_stat(ctx, 'crit_reduction', 'CritDamageReceivedPercent')
end,
},
{
name = 'Heroes with modified bullet resist',
when = function(ctx)
return has_override_or_stat(ctx, 'bullet_resist', 'BulletResist')
end,
},
{
name = 'Heroes with modified spirit resist',
when = function(ctx)
return has_override_or_stat(ctx, 'spirit_resist', 'TechResist')
end,
},
{
name = 'Heroes with modified melee resist',
when = function(ctx)
return has_override_or_stat(ctx, 'melee_resist', 'MeleeResist')
end,
},
{
name = 'Heroes with modified debuff resist',
when = function(ctx)
return has_override_or_stat(ctx, 'debuff_resist', 'DebuffResist')
end,
},
{
name = 'Heroes with modified bullet lifesteal',
when = function(ctx)
return has_override_or_stat(ctx, 'bullet_lifesteal', 'BulletLifesteal')
end,
},
{
name = 'Heroes with modified spirit lifesteal',
when = function(ctx)
return has_override_or_stat(ctx, 'spirit_lifesteal', 'SpiritLifesteal')
end,
},
{
name = 'Heroes with modified gravity scale',
when = function(ctx) return has_stat(ctx.hero, 'GravityChange') end,
},
{
name = function(ctx) return 'Heroes with ' .. ctx.stamina .. ' stamina' end,
when = function(ctx) return ctx.stamina ~= '' end,
},
{
name = function(ctx)
if ctx.dash < 14.7 then return 'Heroes with slow dash speed' end
if ctx.dash > 14.7 then return 'Heroes with fast dash speed' end
return 'Heroes with medium dash speed'
end,
},
{
name = 'Heroes with Street Brawl changes',
when = function(ctx) return sb_module.hero_has_changes(ctx.hero_key) end,
},
}
 
--------------------------------------------------------------------------------
-- Data access
--------------------------------------------------------------------------------
 
has_override_or_stat = function(ctx, param, stat)
if param and ctx.args[param] ~= '' then return true end
return has_stat(ctx.hero, stat)
end
end


function p.get_scalings(hero_data, stat, include_zero)
-- Rounded numeric value of a stat, honouring a |param= override.
local scalings = {}
stat_number = function(ctx, stat, param)
for _, scaling_type in ipairs({"Level", "Spirit"}) do
if param and ctx.args[param] ~= '' then return tonumber(ctx.args[param]) or 0 end
local value = raw_scaling(hero_data, scaling_type, stat)
return tonumber(util.round_to_sig_fig(get_stat(ctx.hero, stat) or 0, DEFAULT_SIG_FIGS)) or 0
if (value ~= nil and (include_zero or value ~= 0)) then
end
table.insert(scalings, { type = scaling_type, value = value })
 
local function format_value(raw, sig_figs)
if raw == nil then return '0' end
local rounded = util.round_to_sig_fig(raw, sig_figs or DEFAULT_SIG_FIGS)
return tostring(rounded ~= nil and rounded or raw)
end
 
--------------------------------------------------------------------------------
-- Rendering helpers
--------------------------------------------------------------------------------
 
local function theme_var(hero_key, name)
local hero_theme = theme_data[hero_key]
if hero_theme and hero_theme[name] ~= nil then return hero_theme[name] end
return theme_data['hero_default'][name]
end
 
local function label_of(row)
if row.label_text then return row.label_text end
if row.label_dict then return dict_module.translate(row.label_dict) end
return lang_module.get_string(row.label)
end
 
-- 'Damage Resistance#Spirit_Resist' -> 'Damage Resistance/ru#Spirit_Resist'
local function page_link(ctx, target)
if not target then return '' end
local page, anchor = target:match('^([^#]*)(#?.*)$')
return page .. ctx.if_lang .. anchor
end
 
local function icon_cell(ctx, row, color, value_html)
local icon = ''
if row.icon then
icon = string.format('[[File:%s|20px|link=%s]]', row.icon,
page_link(ctx, row.icon_link or row.link))
end
return ctx.frame:expandTemplate{ title = 'Icon/' .. color, args = { icon, value_html } }
end
 
-- Ammo and reload time are meaningless for heroes whose weapon never reloads.
local NO_AMMO_STATS = { ClipSize = true, ReloadTime = true }
 
-- Returns true when the row rendered.
local function render_stat_row(ctx, tbl, row, color, is_first)
if row.show and not row.show(ctx) then return false end
if row.optional and not has_override_or_stat(ctx, row.param, row.stat) then return false end
 
local value, scaling_inner
if NO_AMMO_STATS[row.stat] and ctx.args.no_ammo == 'true' then
value, scaling_inner = 'N/A', ''
else
local override = row.param and ctx.args[row.param] or ''
if override ~= '' then
value = override
else
value = format_value(get_stat(ctx.hero, row.stat), row.sig_figs)
end
value = value .. (row.suffix or '')
 
if row.stat and row.scaling ~= false then
local parts = {}
for _, scaling in ipairs(get_scalings(ctx.hero, row.stat)) do
parts[#parts + 1] = hero_module.write_scalar_str(scaling.value, scaling.type)
end
scaling_inner = table.concat(parts)
value = value .. '<span class="infobox-h-spiritboon">' .. scaling_inner .. '</span>'
else
scaling_inner = ''
end
end
 
local tr = tbl:tag('tr')
 
local label_cell = tr:tag('td')
-- The section's first row needs extra headroom when it carries a scaling icon.
if is_first and scaling_inner ~= '' then
label_cell:addClass('infobox-h-stat-label-scaled')
end
if row.link then
label_cell:wikitext(string.format('[[%s|<span class="infobox-h-stat-label">%s</span>]]:',
page_link(ctx, row.link), label_of(row)))
else
label_cell:wikitext(label_of(row) .. ':')
end
 
local value_cell = tr:tag('td')
if row.cell_class then value_cell:addClass(row.cell_class) end
value_cell:wikitext(icon_cell(ctx, row, color, value))
 
return true
end
 
local function render_section(ctx, parent, section)
local tbl = parent:tag('tr'):tag('th')
:attr('colspan', '2')
:addClass('infobox-h-subsection infobox-h-dropdown ' .. section.class)
:tag('table'):addClass('mw-collapsible infobox-h-subsection-child')
 
local caption = tbl:tag('caption'):addClass('infobox-h-dropdown-header')
if ctx.if_lang == '/ru' then
caption:addClass('infobox-h-dropdown-header-overflow')
end
caption:wikitext(string.format(
'[[%s|<span class="infotext-h-bold infobox-h-dropdown-title">%s</span>]]',
page_link(ctx, section.link), lang_module.get_string(section.header)))
 
local is_first = true
for _, row in ipairs(section.rows) do
if render_stat_row(ctx, tbl, row, section.color, is_first) then
is_first = false
end
end
end
end
return scalings
end
end


function p.get_hero_scaling_data(hero_data, hero_stat_key)
local function render_information(ctx, parent)
local scaling_data_returned = {}
local present = {}
-- Walked backwards so Level is written last and wins a value collision,
for _, row in ipairs(INFO_ROWS) do
-- matching the original Spirit-then-Level assignment order.
if ctx.args[row.param] ~= '' then present[#present + 1] = row end
local scalings = p.get_scalings(hero_data, hero_stat_key, true)
end
for i = #scalings, 1, -1 do
if #present == 0 then return end
scaling_data_returned[scalings[i].value] = scalings[i].type
 
parent:tag('tr'):tag('th')
:attr('colspan', '2')
:addClass('infobox-h-text-stat-subtitle')
:wikitext('<span class="infotext-h-bold infotext-h-title infotext-h-big">'
.. dict_module.translate('Information') .. '</span>')
 
local tbl = parent:tag('tr'):tag('th')
:attr('colspan', '2')
:addClass('infobox-h-subsection')
:tag('table'):addClass('infobox-h-subsection-child')
 
for _, row in ipairs(present) do
local tr = tbl:tag('tr')
local label = dict_module.translate(row.label_dict)
if row.tooltip then
label = ctx.frame:expandTemplate{ title = 'Tooltip', args = { label, row.tooltip } }
end
if row.link then
tr:tag('td'):wikitext(string.format('[[%s|<span class="infobox-h-stat-label">%s</span>]]:',
page_link(ctx, row.link), label))
else
tr:tag('td'):wikitext(label .. ':')
end
tr:tag('td'):wikitext(ctx.args[row.param])
end
end
return scaling_data_returned
end
end


local function repr(v)
local function render_title(ctx, parent)
if type(v) ~= 'table' then return tostring(v) end
local th = parent:tag('tr'):tag('th'):attr('colspan', '2'):addClass('infobox-h-title')
local entries = {}
if ctx.args.name_font_size ~= '' then
for k, val in pairs(v) do
th:cssText('font-size: ' .. ctx.args.name_font_size .. ';')
table.insert(entries, tostring(k) .. '=' .. tostring(val))
end
 
local wrap = th:tag('div'):addClass('infobox-h-title-hero')
wrap:tag('div'):addClass('infobox-h-title-hero-left')
 
-- Name logo: <Name>_name.svg, or <Name>_name_<lang>.svg on translated pages.
-- Heroes without a logo fall back to their name as plain text.
local svg = ((ctx.args.name ~= '' and ctx.args.name) or ctx.hero_en) .. '_name'
if ctx.if_lang ~= '' then svg = svg .. '_' .. ctx.lang_code end
 
local middle = wrap:tag('div'):addClass('infobox-h-title-hero-middle')
if util.page_exists('File:' .. svg .. '.svg') then
middle:wikitext(ctx.frame:expandTemplate{ title = 'Icon/White', args = {
string.format('[[File:%s.svg|%s|class=notpageimage]]',
svg, theme_var(ctx.hero_key, 'logo-height')) } })
else
middle:wikitext(ctx.display_name)
end
 
local right = wrap:tag('div'):addClass('infobox-h-title-hero-right'):tag('div')
if ctx.show_icon then
local icon = ((ctx.args.icon ~= '' and ctx.args.icon) or ctx.hero_en) .. '.png'
if util.page_exists('File:' .. icon) then
right:tag('div'):addClass('infobox-h-title-hero-tornbox')
right:tag('div'):addClass('infobox-h-title-hero-icon')
:wikitext(string.format('[[File:%s|center|35px|class=notpageimage]]', icon))
end
end
end
table.sort(entries)
return '{' .. table.concat(entries, ',') .. '}'
end
end


function p.test(frame)
local function render_card(ctx, parent)
local hero_keys, stat_keys, seen = {}, {}, {}
local card = (ctx.args.card ~= '' and ctx.args.card) or (ctx.hero_en .. ' card.png')
for hero_key in pairs(heroes_data) do table.insert(hero_keys, hero_key) end
if not util.page_exists('File:' .. card) then card = FALLBACK_CARD end
table.sort(hero_keys)
parent:tag('tr'):tag('th')
for _, hero in pairs(heroes_data) do
:attr('colspan', '2')
local sources = {
:addClass('infobox-h-image')
hero,
:tag('div')
hero.Weapon or {},
:wikitext(string.format('[[File:%s|150px|alt=%s]]',
(hero.Weapon and hero.Weapon.AltFire) or {},
card, mw.title.getCurrentTitle().baseText))
hero.LevelScaling or {},
end
hero.SpiritScaling or {},
 
}
local function render_tags(ctx, parent)
for _, source in ipairs(sources) do
if not ctx.show_hero_tags then return end
for stat in pairs(source) do
 
if type(stat) == 'string' then seen[stat] = true end
local tags = {}
end
for i = 1, 3 do
local tag = hero_module.hero_tag(ctx.hero_en, i)
if tag ~= '' then
tags[#tags + 1] = tag
end
end
end
end
for stat in pairs(seen) do table.insert(stat_keys, stat) end
if #tags == 0 then return end
table.sort(stat_keys)


local checked, mismatches = 0, {}
local inner = parent:tag('tr'):tag('td')
local zero_cases, collision_cases = 0, 0
:attr('colspan', '2')
:addClass('infobox-h-herotags-row')
:tag('div'):addClass('infobox-h-herotags-inner ' .. ctx.hero_key)
for _, tag in ipairs(tags) do
inner:tag('div'):addClass('infobox-h-herotag-pill'):wikitext(tag)
end
end
 
local function render_categories(ctx)
if mw.ustring.lower(ctx.args.nocat ~= '' and ctx.args.nocat or 'false') ~= 'false' then
return ''
end
if get_stat(ctx.hero, 'IsDisabled') ~= false then return '' end


for _, hero_key in ipairs(hero_keys) do
local out = {}
local hero_data = heroes_data[hero_key]
for _, category in ipairs(CATEGORIES) do
for _, stat in ipairs(stat_keys) do
if category.when == nil or category.when(ctx) then
local a = repr(old_module.get_hero_scaling_data(hero_data, stat))
local name = category.name
local b = repr(p.get_hero_scaling_data(hero_data, stat))
if type(name) == 'function' then name = name(ctx) end
checked = checked + 1
out[#out + 1] = ctx.frame:expandTemplate{ title = 'lang cat', args = { name } }
if a ~= b then
table.insert(mismatches, hero_key .. ' / ' .. stat .. ': ' .. a .. ' vs ' .. b)
end
-- Coverage: does the data actually exercise the tricky paths?
local with_zero = p.get_scalings(hero_data, stat, true)
local without_zero = p.get_scalings(hero_data, stat)
if #with_zero ~= #without_zero then zero_cases = zero_cases + 1 end
if #with_zero == 2 and with_zero[1].value == with_zero[2].value then
collision_cases = collision_cases + 1
end
end
end
end
end
return table.concat(out)
end
--------------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------------
function p.render(frame)
local parent = frame:getParent()
local invoke_args = frame.args
local template_args = parent and parent.args or {}
-- Missing parameters read as '' so callers never have to nil-check.
local args = setmetatable({}, { __index = function(_, k)
local value = invoke_args[k]
if value == nil then value = template_args[k] end
return value or ''
end })


local out = {
-- |key= accepts a hero_* key or the hero's English name; unknown values fall
'Comparisons: ' .. checked,
-- back to the target dummy, the default character in Sandbox mode.
'Mismatches: ' .. #mismatches,
local hero_key = hero_module.find_hero_key(args.key) or DEFAULT_HERO_KEY
'Cases with a zero scaling (shim must keep them): ' .. zero_cases,
local hero = hero_data_all[hero_key]
'Cases where Level and Spirit share a value (collision order): ' .. collision_cases,
 
'',
local ctx = {
frame    = frame,
args      = args,
hero      = hero,
hero_key  = hero_key,
hero_en  = hero.Name,
lang_code = lang_module.get_lang_code(),
if_lang  = frame:expandTemplate{ title = 'If lang' },
}
}
for i, m in ipairs(mismatches) do
ctx.display_name = (args.name ~= '' and args.name) or lang_module.get_string(hero_key)
if i > 30 then table.insert(out, '... and ' .. (#mismatches - 30) .. ' more') break end
 
table.insert(out, '* <code>' .. m .. '</code>')
ctx.show_hero_tags = args.show_hero_tags ~= 'false'
ctx.show_icon      = args.show_icon ~= 'false'
 
-- Needed by both row visibility and the category rules.
ctx.pellets = stat_number(ctx, 'BulletsPerShot', 'pellets_per_shot')
ctx.burst  = stat_number(ctx, 'BulletsPerBurst', 'bullets_per_burst')
ctx.dash    = stat_number(ctx, 'GroundDashSpeed')
ctx.stamina = (args.stamina ~= '' and args.stamina)
or format_value(get_stat(hero, 'Stamina'), 2)
 
local root = mw.html.create('div'):addClass('infobox-h ' .. hero_key)
root:tag('div'):addClass('infobox-h-background')
:wikitext(string.format('[[File:%s|%s|right|link=|class=notpageimage]]',
theme_var(hero_key, 'bg-image'), theme_var(hero_key, 'bg-height')))
 
local tbl = root:tag('table'):addClass('infobox-h-content')
render_title(ctx, tbl)
render_card(ctx, tbl)
render_tags(ctx, tbl)
for _, section in ipairs(SECTIONS) do
render_section(ctx, tbl, section)
end
render_information(ctx, tbl)
 
if mw.title.getCurrentTitle().nsText ~= 'Template' then
frame:callParserFunction('DISPLAYTITLE', ctx.display_name)
end
return tostring(root) .. render_categories(ctx)
end
 
--------------------------------------------------------------------------------
-- TEMPORARY render-diff harness - NOT part of the live module.
-- {{#invoke:Sandbox/Vergir|diff}} or |diff|hero_atlas,hero_viscous
--------------------------------------------------------------------------------
 
-- Kept small: each render makes several expensive page_exists calls, and the
-- parser allows only 100 per page.
local SAMPLE_HEROES = {
'hero_atlas',      -- burst-ish baseline, has scaling on many stats
'hero_viscous',    -- has an AltFire block
'hero_haze',      -- spreadshot
'hero_werewolf',  -- Silver, has a transformed variant
'hero_targetdummy',-- the fallback hero
}
 
function p.diff(frame)
local other = require('Module:InfoboxHero')
local keys = {}
if frame.args[1] and frame.args[1] ~= '' then
for key in string.gmatch(frame.args[1], '[^,%s]+') do
table.insert(keys, key)
end
else
keys = SAMPLE_HEROES
end
end
if #mismatches == 0 then table.insert(out, 'No differences.') end
 
return table.concat(out, '\n')
local checked, mismatches = 0, {}
for _, hero_key in ipairs(keys) do
local child = frame:newChild{ args = { key = hero_key } }
local ok_a, a = pcall(other.render, child)
local ok_b, b = pcall(p.render, child)
if not ok_a then a = 'ERR:' .. tostring(a) end
if not ok_b then b = 'ERR:' .. tostring(b) end
checked = checked + 1
if a ~= b then
table.insert(mismatches, hero_key .. ' (len ' .. #tostring(a) .. ' vs ' .. #tostring(b) .. ')')
end
end
 
return 'Heroes rendered through both modules: ' .. checked ..
'\n\nMismatches: ' .. #mismatches ..
(#mismatches > 0 and ('\n* ' .. table.concat(mismatches, '\n* ')) or '\n\nIdentical output.')
end
end


return p
return p

Revision as of 13:39, 11 August 2026

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

--------------------------------------------------------------------------------
-- Module:InfoboxHero
--
-- Renders the hero infobox. Called from [[Template:Infobox hero]], which passes
-- the editor's parameters through as the parent frame:
--
--     {{#invoke:InfoboxHero|render}}
--
-- May also be invoked directly for testing, in which case parameters are read
-- from the invoke itself:
--
--     {{#invoke:InfoboxHero|render|key=hero_atlas}}
--
-- Stat data comes from [[Data:HeroData.json]]; per-hero colors, backgrounds and
-- logo sizes from [[Data:Infobox hero.json]]; labels from [[Module:Lang]] and
-- [[Module:Dictionary]]. Styling lives in [[Template:Infobox hero/styles.css]]
-- and [[Template:Infobox hero/themes.css]].
--
--------------------------------------------------------------------------------

local p = {}

local hero_data_all = mw.loadJsonData('Data:HeroData.json')
local theme_data    = mw.loadJsonData('Data:Infobox_hero.json')

local util        = require('Module:Utilities')
local lang_module = require('Module:Lang')
local dict_module = require('Module:Dictionary')
local hero_module = require('Module:HeroData')
local sb_module   = require('Module:StreetBrawlAbilities')

-- Shared hero-data lookups, see [[Module:HeroData]].
local get_stat     = hero_module.get_stat
local get_scalings = hero_module.get_scalings
local has_stat     = hero_module.has_stat

local has_override_or_stat, stat_number

--------------------------------------------------------------------------------
-- CONFIG
--------------------------------------------------------------------------------

local DEFAULT_HERO_KEY = 'hero_targetdummy'  -- used when |key= is missing/unknown
local DEFAULT_SIG_FIGS = 3
local FALLBACK_CARD    = 'Dummy card.png'

-- Row fields (all optional except a label):
--   stat        Key in Data:HeroData.json. Resolved top-level, then .Weapon,
--               then .Weapon.AltFire. Omit for manual-input-only rows.
--   param       |param= that overrides the value.
--   label       Key in Data:Lang_<code>.json.
--   label_dict  Key in Data:Dictionary. Used instead of `label`.
--   label_text  Literal, untranslated label. Used instead of `label`.
--   link        Target for the label, as 'Page' or 'Page#Anchor'. The language
--               suffix is inserted for us, so write the plain English title.
--   icon_link   Target for the icon. Defaults to `link`.
--   icon        File: name of the value icon. Omit for no icon.
--   sig_figs    Significant figures. Defaults to 3.
--   suffix      Appended directly after the value, e.g. '%', 's', 'm/s'.
--   scaling     Set false to suppress the Boon/Spirit scaling span. Scaling is
--               shown by default wherever the data provides it.
--   optional    Hide the row unless the hero has a non-zero value or scaling.
--   cell_class  Extra class on the value cell.
--   show        function(ctx) -> boolean. Row omitted when false.

local SECTIONS = {
	{
		class  = 'infobox-h-dropdown-weapon',
		header = 'CitadelHeroStats_Weapon',
		link   = 'Stats#Weapon',
		color  = 'Brown',
		rows = {
			{
				stat    = 'DPS',
				param   = 'dps',
				label   = 'DPS_label',
				link    = 'Damage per second',
				icon    = 'Damage.png',
				-- DPS is derived from bullet damage and fire rate, both of which
				-- show their own scaling; repeating it here would double-count.
				scaling = false,
			},
			{
				stat       = 'BulletDamage',
				param      = 'bullet_damage',
				label      = 'StatDesc_BulletDamage',
				link       = 'Bullet Damage',
				icon       = 'Bullet damage.png',
				cell_class = 'infobox-h-stat-value-padded',
			},
			{
				stat      = 'BulletsPerShot',
				param     = 'pellets_per_shot',
				label     = 'BonusPelletsPerShot_label',
				link      = 'Damage per second',
				icon      = 'Bullets per sec icon.png',
				icon_link = 'Fire Rate',
				show      = function(ctx) return ctx.pellets > 1 end,
			},
			{
				stat       = 'BulletsPerBurst',
				param      = 'bullets_per_burst',
				label_dict = 'BulletsPerBurst',
				link       = 'Damage per second',
				icon       = 'Bullets per sec icon.png',
				icon_link  = 'Fire Rate',
				show       = function(ctx) return ctx.burst > 1 end,
			},
			{
				stat  = 'ClipSize',
				param = 'ammo',
				label = 'StatDesc_ClipSizeBonus',
				link  = 'Ammo',
				icon  = 'Clip size icon.png',
			},
			{
				stat  = 'RoundsPerSecond',
				param = 'bullets_per_sec',
				label = 'StatDesc_RoundsPerSecond',
				link  = 'Fire Rate',
				icon  = 'Bullets per sec icon.png',
			},
			{
				stat   = 'ReloadTime',
				param  = 'reload_time',
				label  = 'StatDesc_ReloadTime',
				link   = 'Reload Time',
				icon   = 'AttributeIconReloadTime.png',
				suffix = 's',
			},
			{
				stat   = 'BulletSpeed',
				param  = 'bullet_velocity',
				label  = 'StatDesc_BulletSpeed',
				link   = 'Bullet Velocity',
				icon   = 'AttributeIconBulletSpeed.png',
				suffix = 'm/s',
			},
			{
				stat       = 'LightMeleeDamage',
				param      = 'light_melee',
				label      = 'StatDesc_LightMeleeDamage',
				link       = 'Melee Damage',
				icon       = 'Melee damage.png',
				cell_class = 'infobox-h-stat-value-padded',
			},
			{
				stat       = 'HeavyMeleeDamage',
				param      = 'heavy_melee',
				label      = 'StatDesc_HeavyMeleeDamage',
				link       = 'Melee Damage',
				icon       = 'Melee damage.png',
				cell_class = 'infobox-h-stat-value-padded',
			},
			{
				param      = 'max_range',
				label_text = 'Max Range',
				link       = 'Falloff Range',
				show       = function(ctx) return ctx.args.max_range ~= '' end,
			},
			{
				stat   = 'CritDamageBonusPercent',
				label  = 'StatDesc_CritDamageBonusScale',
				link   = 'Weapon Damage#Crit_Multiplier',
				icon   = 'Crit reduction.png',
				suffix = '%',
				show   = function(ctx)
					return stat_number(ctx, 'CritDamageBonusPercent') ~= 0
				end,
			},
		},
	},
	{
		class  = 'infobox-h-dropdown-vitality',
		header = 'CitadelHeroStats_Vitality',
		link   = 'Stats#Vitality',
		color  = 'Green',
		rows = {
			{
				stat  = 'MaxHealth',
				param = 'max_health',
				label = 'StatDesc_HealthAttribute',
				link  = 'Health',
				icon  = 'Health.png',
			},
			{
				stat     = 'BaseHealthRegen',
				param    = 'health_regen',
				label    = 'StatDesc_HealthRegen',
				link     = 'Health Regen',
				icon     = 'Health regen.png',
				sig_figs = 2,
			},
			{
				stat     = 'CritDamageReceivedPercent',
				param    = 'crit_reduction',
				label    = 'StatDesc_CritDamageReceivedScale',
				link     = 'Damage Resistance#Crit_Reduction',
				icon     = 'Crit reduction.png',
				suffix   = '%',
				optional = true,
			},
			{
				stat      = 'BulletResist',
				param     = 'bullet_resist',
				label     = 'StatDesc_BulletArmorDamageReduction',
				link      = 'Damage Resistance',
				icon      = 'Bullet Armor.png',
				icon_link = 'Bullet Resist',
				suffix    = '%',
				optional  = true,
			},
			{
				stat     = 'TechResist',
				param    = 'spirit_resist',
				label    = 'StatDesc_TechArmorDamageReduction',
				link     = 'Damage Resistance#Spirit_Resist',
				icon     = 'Spirit Armor.png',
				suffix   = '%',
				optional = true,
			},
			{
				stat     = 'MeleeResist',
				param    = 'melee_resist',
				label    = 'StatDesc_MeleeResist',
				link     = 'Damage Resistance#Melee Resist',
				icon     = 'Melee damage.png',
				suffix   = '%',
				optional = true,
			},
			{
				stat     = 'DebuffResist',
				param    = 'debuff_resist',
				label    = 'StatDesc_DebuffResist',
				link     = 'Debuff Resist',
				icon     = 'Spirit Armor.png',
				suffix   = '%',
				optional = true,
			},
			{
				stat     = 'BulletLifesteal',
				param    = 'bullet_lifesteal',
				label    = 'StatDesc_BulletLifesteal',
				link     = 'Lifesteal#Bullet Lifesteal',
				icon     = 'AttributeIconBulletLifesteal.png',
				suffix   = '%',
				optional = true,
			},
			{
				stat     = 'SpiritLifesteal',
				param    = 'spirit_lifesteal',
				label    = 'StatDesc_SpiritLifesteal',
				link     = 'Lifesteal#Spirit Lifesteal',
				icon     = 'AttributeIconBulletLifesteal.png',
				suffix   = '%',
				optional = true,
			},
			{
				stat     = 'MaxMoveSpeed',
				param    = 'move_speed',
				label    = 'StatDesc_RunSpeed',
				link     = 'Move Speed',
				icon     = 'Move speed.png',
				sig_figs = 2,
				suffix   = 'm/s',
			},
			{
				stat     = 'SprintSpeed',
				param    = 'sprint_speed',
				label    = 'StatDesc_SprintSpeed',
				link     = 'Move Speed#Sprint',
				icon     = 'Move speed sprint.png',
				sig_figs = 2,
				suffix   = 'm/s',
			},
			{
				stat   = 'GroundDashSpeed',
				label  = 'DashSpeed_label',
				link   = 'Stamina#Dash_Speed',
				icon   = 'Springy boots icon.png',
				suffix = 'm/s',
			},
			{
				stat     = 'Stamina',
				param    = 'stamina',
				label    = 'StatDesc_Stamina',
				link     = 'Stamina',
				icon     = 'Springy boots icon.png',
				sig_figs = 2,
			},
			{
				stat     = 'StaminaCooldown',
				param    = 'stamina_cooldown',
				label    = 'StatDesc_StaminaCooldown',
				link     = 'Stamina#Cooldown',
				icon     = 'AttributeIconTechDuration.png',
				sig_figs = 2,
				suffix   = 's',
			},
		},
	},
	{
		class  = 'infobox-h-dropdown-spirit',
		header = 'CitadelHeroStats_Spirit',
		link   = 'Stats#Spirit',
		color  = 'Purple',
		rows = {
			{
				stat     = 'TechPower',
				param    = 'spirit_power',
				label    = 'SpiritPower_label',
				link     = 'Spirit Power',
				icon     = 'Spirit icon noborder.png',
				sig_figs = 2,
			},
		},
	},
}

-- The "Information" block. Manual input only; hidden entirely when all are empty.
local INFO_ROWS = {
	{ param = 'pronouns',     label_dict = 'Pronouns' },
	{ param = 'voice_actor',  label_dict = 'VoiceActor', link = 'Voice actors' },
	{ param = 'release_date', label_dict = 'ReleaseDate', link = 'Heroes by release' },
	{ param = 'codenames',    label_dict = 'Codenames', tooltip = 'Codenames used for this hero in the game files' },
}

-- name: literal, or function(ctx) -> string.  when: function(ctx) -> boolean.
local CATEGORIES = {
	{ name = 'Heroes' },
	{
		name = function(ctx) return ctx.hero_en end,
		-- Silver (Transformed) is documented on Silver's page and shares her category.
		when = function(ctx) return ctx.hero_en ~= 'Silver (Transformed)' end,
	},
	{
		name = 'Heroes with spreadshot weapons',
		when = function(ctx) return ctx.pellets > 1 end,
	},
	{
		name = 'Heroes with single damage spreadshot weapons',
		when = function(ctx)
			return ctx.pellets > 1 and get_stat(ctx.hero, 'HitOnceAcrossAllBullets') == true
		end,
	},
	{
		name = 'Heroes with burst weapons',
		when = function(ctx) return ctx.burst > 1 end,
	},
	{
		name = 'Heroes with automatic weapons',
		when = function(ctx) return ctx.pellets <= 1 and ctx.burst <= 1 end,
	},
	{
		name = 'Heroes with alternate fire weapons',
		when = function(ctx)
			return ctx.hero.Weapon ~= nil and ctx.hero.Weapon.AltFire ~= nil
		end,
	},
	{
		name = 'Heroes with single reload weapons',
		when = function(ctx) return get_stat(ctx.hero, 'ReloadSingle') == true end,
	},
	{
		name = 'Heroes with spin up weapons',
		when = function(ctx) return has_stat(ctx.hero, 'SpinAcceleration') end,
	},
	{
		name = 'Heroes with no weapon damage falloff',
		when = function(ctx)
			return (get_stat(ctx.hero, 'FalloffStartRange') or 0)
			    == (get_stat(ctx.hero, 'FalloffEndRange') or 0)
		end,
	},
	{
		name = "Heroes that don't deal critical damage",
		when = function(ctx) return get_stat(ctx.hero, 'CanCrit') == false end,
	},
	{
		name = 'Heroes with modified crit scale',
		when = function(ctx) return stat_number(ctx, 'CritDamageBonusPercent') ~= 0 end,
	},
	{
		name = 'Heroes with modified crit resistance',
		when = function(ctx)
			return has_override_or_stat(ctx, 'crit_reduction', 'CritDamageReceivedPercent')
		end,
	},
	{
		name = 'Heroes with modified bullet resist',
		when = function(ctx)
			return has_override_or_stat(ctx, 'bullet_resist', 'BulletResist')
		end,
	},
	{
		name = 'Heroes with modified spirit resist',
		when = function(ctx)
			return has_override_or_stat(ctx, 'spirit_resist', 'TechResist')
		end,
	},
	{
		name = 'Heroes with modified melee resist',
		when = function(ctx)
			return has_override_or_stat(ctx, 'melee_resist', 'MeleeResist')
		end,
	},
	{
		name = 'Heroes with modified debuff resist',
		when = function(ctx)
			return has_override_or_stat(ctx, 'debuff_resist', 'DebuffResist')
		end,
	},
	{
		name = 'Heroes with modified bullet lifesteal',
		when = function(ctx)
			return has_override_or_stat(ctx, 'bullet_lifesteal', 'BulletLifesteal')
		end,
	},
	{
		name = 'Heroes with modified spirit lifesteal',
		when = function(ctx)
			return has_override_or_stat(ctx, 'spirit_lifesteal', 'SpiritLifesteal')
		end,
	},
	{
		name = 'Heroes with modified gravity scale',
		when = function(ctx) return has_stat(ctx.hero, 'GravityChange') end,
	},
	{
		name = function(ctx) return 'Heroes with ' .. ctx.stamina .. ' stamina' end,
		when = function(ctx) return ctx.stamina ~= '' end,
	},
	{
		name = function(ctx)
			if ctx.dash < 14.7 then return 'Heroes with slow dash speed' end
			if ctx.dash > 14.7 then return 'Heroes with fast dash speed' end
			return 'Heroes with medium dash speed'
		end,
	},
	{
		name = 'Heroes with Street Brawl changes',
		when = function(ctx) return sb_module.hero_has_changes(ctx.hero_key) end,
	},
}

--------------------------------------------------------------------------------
-- Data access
--------------------------------------------------------------------------------

has_override_or_stat = function(ctx, param, stat)
	if param and ctx.args[param] ~= '' then return true end
	return has_stat(ctx.hero, stat)
end

-- Rounded numeric value of a stat, honouring a |param= override.
stat_number = function(ctx, stat, param)
	if param and ctx.args[param] ~= '' then return tonumber(ctx.args[param]) or 0 end
	return tonumber(util.round_to_sig_fig(get_stat(ctx.hero, stat) or 0, DEFAULT_SIG_FIGS)) or 0
end

local function format_value(raw, sig_figs)
	if raw == nil then return '0' end
	local rounded = util.round_to_sig_fig(raw, sig_figs or DEFAULT_SIG_FIGS)
	return tostring(rounded ~= nil and rounded or raw)
end

--------------------------------------------------------------------------------
-- Rendering helpers
--------------------------------------------------------------------------------

local function theme_var(hero_key, name)
	local hero_theme = theme_data[hero_key]
	if hero_theme and hero_theme[name] ~= nil then return hero_theme[name] end
	return theme_data['hero_default'][name]
end

local function label_of(row)
	if row.label_text then return row.label_text end
	if row.label_dict then return dict_module.translate(row.label_dict) end
	return lang_module.get_string(row.label)
end

-- 'Damage Resistance#Spirit_Resist' -> 'Damage Resistance/ru#Spirit_Resist'
local function page_link(ctx, target)
	if not target then return '' end
	local page, anchor = target:match('^([^#]*)(#?.*)$')
	return page .. ctx.if_lang .. anchor
end

local function icon_cell(ctx, row, color, value_html)
	local icon = ''
	if row.icon then
		icon = string.format('[[File:%s|20px|link=%s]]', row.icon,
			page_link(ctx, row.icon_link or row.link))
	end
	return ctx.frame:expandTemplate{ title = 'Icon/' .. color, args = { icon, value_html } }
end

-- Ammo and reload time are meaningless for heroes whose weapon never reloads.
local NO_AMMO_STATS = { ClipSize = true, ReloadTime = true }

-- Returns true when the row rendered.
local function render_stat_row(ctx, tbl, row, color, is_first)
	if row.show and not row.show(ctx) then return false end
	if row.optional and not has_override_or_stat(ctx, row.param, row.stat) then return false end

	local value, scaling_inner
	if NO_AMMO_STATS[row.stat] and ctx.args.no_ammo == 'true' then
		value, scaling_inner = 'N/A', ''
	else
		local override = row.param and ctx.args[row.param] or ''
		if override ~= '' then
			value = override
		else
			value = format_value(get_stat(ctx.hero, row.stat), row.sig_figs)
		end
		value = value .. (row.suffix or '')

		if row.stat and row.scaling ~= false then
			local parts = {}
			for _, scaling in ipairs(get_scalings(ctx.hero, row.stat)) do
				parts[#parts + 1] = hero_module.write_scalar_str(scaling.value, scaling.type)
			end
			scaling_inner = table.concat(parts)
			value = value .. '<span class="infobox-h-spiritboon">' .. scaling_inner .. '</span>'
		else
			scaling_inner = ''
		end
	end

	local tr = tbl:tag('tr')

	local label_cell = tr:tag('td')
	-- The section's first row needs extra headroom when it carries a scaling icon.
	if is_first and scaling_inner ~= '' then
		label_cell:addClass('infobox-h-stat-label-scaled')
	end
	if row.link then
		label_cell:wikitext(string.format('[[%s|<span class="infobox-h-stat-label">%s</span>]]:',
			page_link(ctx, row.link), label_of(row)))
	else
		label_cell:wikitext(label_of(row) .. ':')
	end

	local value_cell = tr:tag('td')
	if row.cell_class then value_cell:addClass(row.cell_class) end
	value_cell:wikitext(icon_cell(ctx, row, color, value))

	return true
end

local function render_section(ctx, parent, section)
	local tbl = parent:tag('tr'):tag('th')
		:attr('colspan', '2')
		:addClass('infobox-h-subsection infobox-h-dropdown ' .. section.class)
		:tag('table'):addClass('mw-collapsible infobox-h-subsection-child')

	local caption = tbl:tag('caption'):addClass('infobox-h-dropdown-header')
	if ctx.if_lang == '/ru' then
		caption:addClass('infobox-h-dropdown-header-overflow')
	end
	caption:wikitext(string.format(
		'[[%s|<span class="infotext-h-bold infobox-h-dropdown-title">%s</span>]]',
		page_link(ctx, section.link), lang_module.get_string(section.header)))

	local is_first = true
	for _, row in ipairs(section.rows) do
		if render_stat_row(ctx, tbl, row, section.color, is_first) then
			is_first = false
		end
	end
end

local function render_information(ctx, parent)
	local present = {}
	for _, row in ipairs(INFO_ROWS) do
		if ctx.args[row.param] ~= '' then present[#present + 1] = row end
	end
	if #present == 0 then return end

	parent:tag('tr'):tag('th')
		:attr('colspan', '2')
		:addClass('infobox-h-text-stat-subtitle')
		:wikitext('<span class="infotext-h-bold infotext-h-title infotext-h-big">'
			.. dict_module.translate('Information') .. '</span>')

	local tbl = parent:tag('tr'):tag('th')
		:attr('colspan', '2')
		:addClass('infobox-h-subsection')
		:tag('table'):addClass('infobox-h-subsection-child')

	for _, row in ipairs(present) do
		local tr = tbl:tag('tr')
		local label = dict_module.translate(row.label_dict)
		if row.tooltip then
			label = ctx.frame:expandTemplate{ title = 'Tooltip', args = { label, row.tooltip } }
		end
		if row.link then
			tr:tag('td'):wikitext(string.format('[[%s|<span class="infobox-h-stat-label">%s</span>]]:',
				page_link(ctx, row.link), label))
		else
			tr:tag('td'):wikitext(label .. ':')
		end
		tr:tag('td'):wikitext(ctx.args[row.param])
	end
end

local function render_title(ctx, parent)
	local th = parent:tag('tr'):tag('th'):attr('colspan', '2'):addClass('infobox-h-title')
	if ctx.args.name_font_size ~= '' then
		th:cssText('font-size: ' .. ctx.args.name_font_size .. ';')
	end

	local wrap = th:tag('div'):addClass('infobox-h-title-hero')
	wrap:tag('div'):addClass('infobox-h-title-hero-left')

	-- Name logo: <Name>_name.svg, or <Name>_name_<lang>.svg on translated pages.
	-- Heroes without a logo fall back to their name as plain text.
	local svg = ((ctx.args.name ~= '' and ctx.args.name) or ctx.hero_en) .. '_name'
	if ctx.if_lang ~= '' then svg = svg .. '_' .. ctx.lang_code end

	local middle = wrap:tag('div'):addClass('infobox-h-title-hero-middle')
	if util.page_exists('File:' .. svg .. '.svg') then
		middle:wikitext(ctx.frame:expandTemplate{ title = 'Icon/White', args = {
			string.format('[[File:%s.svg|%s|class=notpageimage]]',
				svg, theme_var(ctx.hero_key, 'logo-height')) } })
	else
		middle:wikitext(ctx.display_name)
	end

	local right = wrap:tag('div'):addClass('infobox-h-title-hero-right'):tag('div')
	if ctx.show_icon then
		local icon = ((ctx.args.icon ~= '' and ctx.args.icon) or ctx.hero_en) .. '.png'
		if util.page_exists('File:' .. icon) then
			right:tag('div'):addClass('infobox-h-title-hero-tornbox')
			right:tag('div'):addClass('infobox-h-title-hero-icon')
				:wikitext(string.format('[[File:%s|center|35px|class=notpageimage]]', icon))
		end
	end
end

local function render_card(ctx, parent)
	local card = (ctx.args.card ~= '' and ctx.args.card) or (ctx.hero_en .. ' card.png')
	if not util.page_exists('File:' .. card) then card = FALLBACK_CARD end
	parent:tag('tr'):tag('th')
		:attr('colspan', '2')
		:addClass('infobox-h-image')
		:tag('div')
		:wikitext(string.format('[[File:%s|150px|alt=%s]]',
			card, mw.title.getCurrentTitle().baseText))
end

local function render_tags(ctx, parent)
	if not ctx.show_hero_tags then return end

	local tags = {}
	for i = 1, 3 do
		local tag = hero_module.hero_tag(ctx.hero_en, i)
		if tag ~= '' then
			tags[#tags + 1] = tag
		end
	end
	if #tags == 0 then return end

	local inner = parent:tag('tr'):tag('td')
		:attr('colspan', '2')
		:addClass('infobox-h-herotags-row')
		:tag('div'):addClass('infobox-h-herotags-inner ' .. ctx.hero_key)
	for _, tag in ipairs(tags) do
		inner:tag('div'):addClass('infobox-h-herotag-pill'):wikitext(tag)
	end
end

local function render_categories(ctx)
	if mw.ustring.lower(ctx.args.nocat ~= '' and ctx.args.nocat or 'false') ~= 'false' then
		return ''
	end
	if get_stat(ctx.hero, 'IsDisabled') ~= false then return '' end

	local out = {}
	for _, category in ipairs(CATEGORIES) do
		if category.when == nil or category.when(ctx) then
			local name = category.name
			if type(name) == 'function' then name = name(ctx) end
			out[#out + 1] = ctx.frame:expandTemplate{ title = 'lang cat', args = { name } }
		end
	end
	return table.concat(out)
end

--------------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------------

function p.render(frame)
	local parent = frame:getParent()
	local invoke_args = frame.args
	local template_args = parent and parent.args or {}

	-- Missing parameters read as '' so callers never have to nil-check.
	local args = setmetatable({}, { __index = function(_, k)
		local value = invoke_args[k]
		if value == nil then value = template_args[k] end
		return value or ''
	end })

	-- |key= accepts a hero_* key or the hero's English name; unknown values fall
	-- back to the target dummy, the default character in Sandbox mode.
	local hero_key = hero_module.find_hero_key(args.key) or DEFAULT_HERO_KEY
	local hero = hero_data_all[hero_key]

	local ctx = {
		frame     = frame,
		args      = args,
		hero      = hero,
		hero_key  = hero_key,
		hero_en   = hero.Name,
		lang_code = lang_module.get_lang_code(),
		if_lang   = frame:expandTemplate{ title = 'If lang' },
	}
	ctx.display_name = (args.name ~= '' and args.name) or lang_module.get_string(hero_key)

	ctx.show_hero_tags = args.show_hero_tags ~= 'false'
	ctx.show_icon      = args.show_icon ~= 'false'

	-- Needed by both row visibility and the category rules.
	ctx.pellets = stat_number(ctx, 'BulletsPerShot', 'pellets_per_shot')
	ctx.burst   = stat_number(ctx, 'BulletsPerBurst', 'bullets_per_burst')
	ctx.dash    = stat_number(ctx, 'GroundDashSpeed')
	ctx.stamina = (args.stamina ~= '' and args.stamina)
		or format_value(get_stat(hero, 'Stamina'), 2)

	local root = mw.html.create('div'):addClass('infobox-h ' .. hero_key)
	root:tag('div'):addClass('infobox-h-background')
		:wikitext(string.format('[[File:%s|%s|right|link=|class=notpageimage]]',
			theme_var(hero_key, 'bg-image'), theme_var(hero_key, 'bg-height')))

	local tbl = root:tag('table'):addClass('infobox-h-content')
	render_title(ctx, tbl)
	render_card(ctx, tbl)
	render_tags(ctx, tbl)
	for _, section in ipairs(SECTIONS) do
		render_section(ctx, tbl, section)
	end
	render_information(ctx, tbl)

	if mw.title.getCurrentTitle().nsText ~= 'Template' then
		frame:callParserFunction('DISPLAYTITLE', ctx.display_name)
	end
	return tostring(root) .. render_categories(ctx)
end

--------------------------------------------------------------------------------
-- TEMPORARY render-diff harness - NOT part of the live module.
-- {{#invoke:Sandbox/Vergir|diff}} or |diff|hero_atlas,hero_viscous
--------------------------------------------------------------------------------

-- Kept small: each render makes several expensive page_exists calls, and the
-- parser allows only 100 per page.
local SAMPLE_HEROES = {
	'hero_atlas',      -- burst-ish baseline, has scaling on many stats
	'hero_viscous',    -- has an AltFire block
	'hero_haze',       -- spreadshot
	'hero_werewolf',   -- Silver, has a transformed variant
	'hero_targetdummy',-- the fallback hero
}

function p.diff(frame)
	local other = require('Module:InfoboxHero')
	local keys = {}
	if frame.args[1] and frame.args[1] ~= '' then
		for key in string.gmatch(frame.args[1], '[^,%s]+') do
			table.insert(keys, key)
		end
	else
		keys = SAMPLE_HEROES
	end

	local checked, mismatches = 0, {}
	for _, hero_key in ipairs(keys) do
		local child = frame:newChild{ args = { key = hero_key } }
		local ok_a, a = pcall(other.render, child)
		local ok_b, b = pcall(p.render, child)
		if not ok_a then a = 'ERR:' .. tostring(a) end
		if not ok_b then b = 'ERR:' .. tostring(b) end
		checked = checked + 1
		if a ~= b then
			table.insert(mismatches, hero_key .. ' (len ' .. #tostring(a) .. ' vs ' .. #tostring(b) .. ')')
		end
	end

	return 'Heroes rendered through both modules: ' .. checked ..
		'\n\nMismatches: ' .. #mismatches ..
		(#mismatches > 0 and ('\n* ' .. table.concat(mismatches, '\n* ')) or '\n\nIdentical output.')
end

return p