Module:InfoboxHero

Revision as of 20:21, 25 August 2026 by Mgpt (talk | contribs) (Attempt to only add 2 categories for removed/unreleased heroes)

Documentation for this module may be created at Module:InfoboxHero/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 = function(ctx) return ctx.is_disabled and 'Unused heroes' or 'Heroes' end,
        always = true,
    },
	{
		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,
        always = true,
	},
	{
		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

    local is_disabled = get_stat(ctx.hero, 'IsDisabled')
    if is_disabled == nil then return '' end
    ctx.is_disabled = is_disabled

	local out = {}
	for _, category in ipairs(CATEGORIES) do
        if is_disabled and not category.always then
		elseif 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

return p