Module:InfoboxHero: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
move back to fancy tooltips, also add classes to label spans
added link for voice actor, adjusted order
Line 314: Line 314:
-- The "Information" block. Manual input only; hidden entirely when all are empty.
-- The "Information" block. Manual input only; hidden entirely when all are empty.
local INFO_ROWS = {
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 = 'release_date', label_dict = 'ReleaseDate', link = 'Heroes by release' },
{ param = 'pronouns',    label_dict = 'Pronouns' },
{ param = 'voice_actor',  label_dict = 'VoiceActor' },
{ param = 'codenames',    label_dict = 'Codenames', tooltip = 'Codenames used for this hero in the game files' },
{ param = 'codenames',    label_dict = 'Codenames', tooltip = 'Codenames used for this hero in the game files' },
}
}

Revision as of 22:49, 21 July 2026

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 get_stat, has_stat, 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,
	},
}

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

-- Stats live at the top level for vitality, and under .Weapon for weapon stats.
get_stat = function(hero, stat)
	if hero == nil or stat == nil then return nil end
	local value = hero[stat]
	if value == nil and hero.Weapon then
		value = hero.Weapon[stat]
		if value == nil and hero.Weapon.AltFire then
			value = hero.Weapon.AltFire[stat]
		end
	end
	return value
end

-- The raw Boon (Level) and Spirit scaling values for a stat, if any.
local function get_scaling(hero, stat)
	local out = {}
	for _, scaling_type in ipairs({ 'Level', 'Spirit' }) do
		local scaling = hero[scaling_type .. 'Scaling']
		local value = scaling and stat and scaling[stat]
		if value ~= nil and value ~= 0 then
			out[#out + 1] = { type = scaling_type, value = value }
		end
	end
	return out
end

-- True when the hero has a non-zero base value, or any scaling, for a stat.
has_stat = function(hero, stat)
	local value = get_stat(hero, stat)
	if value ~= nil and value ~= 0 then return true end
	return #get_scaling(hero, stat) > 0
end

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_scaling(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.get_hero_tag({ args = { ctx.hero_en, tostring(i) } })
		if tag and tag ~= '' and not tag:find('Error:', 1, true) 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
--------------------------------------------------------------------------------

-- |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 function resolve_hero_key(provided)
	if provided == nil or provided == '' then return DEFAULT_HERO_KEY end
	if hero_data_all[provided] ~= nil then return provided end
	local wanted = mw.ustring.lower(provided)
	for key, hero in pairs(hero_data_all) do
		if hero.Name and mw.ustring.lower(hero.Name) == wanted then return key end
	end
	return DEFAULT_HERO_KEY
end

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 hero_key = resolve_hero_key(args.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