Module:Sandbox/Vergir: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Vergir (talk | contribs)
Testing InfoboxHero on shared HeroData helpers (with help from vergir-bot LLM)
Vergir (talk | contribs)
Use {{Ss}} badge in the Spirit Scaling cell; add plain-text dump for review (with help from vergir-bot LLM)
 
(15 intermediate revisions by 2 users not shown)
Line 1: Line 1:
--------------------------------------------------------------------------------
-- Renders sortable wikitables of abilities filtered by a named stat.
-- Module:InfoboxHero
 
--
local Lists     = require("Module:AbilityTable/Lists")
-- Renders the hero infobox. Called from [[Template:Infobox hero]], which passes
local Notes     = require("Module:AbilityTable/Notes")
-- the editor's parameters through as the parent frame:
local Renderers = require("Module:AbilityTable/ComplexRenderers")
--
local GameData  = require("Module:GameData")
--     {{#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 hero_data_all = mw.loadJsonData('Data:HeroData.json')
-- ============================================================
local theme_data   = mw.loadJsonData('Data:Infobox_hero.json')
-- TEST COPY of Module:AbilityTable with the proposed
-- Module:AbilityTable/ComplexRenderers.RenderSpiritScaling inlined below,
-- so the whole change can be previewed from one sandbox module.
-- ============================================================
 
-- ============================================================
-- Spirit scaling helpers
-- ============================================================
 
-- Returns the Spirit scaling entry of a prop value, or nil. A prop's Scale is
-- either a single { Value, Type } table or an array of them (a duration that
-- scales with both Spirit and ability duration, for example).
local function spirit_scale(v)
    if type(v) ~= "table" then return nil end
    local scale = v["Scale"]
    if type(scale) ~= "table" then return nil end
    if scale[1] == nil then
        if scale["Type"] == "spirit" then return scale end
        return nil
    end
    for _, entry in ipairs(scale) do
        if type(entry) == "table" and entry["Type"] == "spirit" then return entry end
    end
    return nil
end
 
-- Collects the non-zero Spirit scalings of one record layer (the base record
-- or one upgrade tier) as { key, value, multiply }, ordered the way the
-- ability card lists its props; props the card does not list come last, in
-- key order. `labels` is the view's cardprops map (see Module:AbilityTable).
local function spirit_scalings(layer, labels)
    local out = {}
    for k, v in pairs(layer) do
        if k ~= "Upgrades" and tostring(k):sub(1, 1) ~= "_" then
            local s = spirit_scale(v)
            if s and type(s["Value"]) == "number" and s["Value"] ~= 0 then
                table.insert(out, {
                    key      = k,
                    value   = s["Value"],
                    multiply = s["Multiply"] == true,
                })
            end
        end
    end
    table.sort(out, function(a, b)
        local oa = labels[a.key] and labels[a.key].order or math.huge
        local ob = labels[b.key] and labels[b.key].order or math.huge
        if oa ~= ob then return oa < ob end
        return a.key < b.key
    end)
    return out
end
 
-- Formats a scaling value to 3 significant figures, as the ability cards do.
local function format_spirit(v)
    return string.format("%.3g", v)
end
 
-- Formats one Spirit scaling for the table cell through the compact {{Ss}}
-- form, the same way RenderBarrier does. The template is expanded via the
-- frame the surrounding #invoke runs under, since module return values are
-- not re-expanded; the plain "×N" fallback covers a nil frame (module
-- console).
local function format_spirit_badge(v)
    local text  = format_spirit(v)
    local frame = mw.getCurrentFrame()
    if frame then
        return frame:expandTemplate{
            title = "Ss",
            args  = { text, compact = "1", show_value = "1" },
        }
    end
    return "×" .. text
end
 
-- "A", "A and B", "A, B and C".
local function join_names(names)
    if #names <= 1 then return names[1] or "" end
    return table.concat(names, ", ", 1, #names - 1) .. " and " .. names[#names]
end


local util        = require('Module:Utilities')
-- Renders Spirit Power scaling.
local lang_module = require('Module:Lang')
-- Lists every prop that scales with Spirit at base, one per line as an {{Ss}}
local dict_module = require('Module:Dictionary')
-- badge labelled with the prop's name from the ability card:
local hero_module = require('Module:HeroData')
-- "x0.55 (Base Damage)". Two
local sb_module  = require('Module:StreetBrawlAbilities')
-- props sharing a name are told apart by the card's Title
-- ("Damage: Cost of Stay"); props the card does not list show their raw key.
-- Upgrades are reported in Notes per prop, with the running total: additive
-- ones as "adds +×0.5 ... (total ×1.05)", ones that unlock scaling on a prop
-- as "grants ×1.3 ...", and Multiply upgrades as "multiplies ... by 1.15",
-- which is how the ability-upgrade gadget applies them.
function p.RenderSpiritScaling(ability, cardprops)
    local labels = cardprops or {}


-- Shared hero-data lookups, see [[Module:HeroData]].
    local base  = spirit_scalings(ability, labels)
local get_stat     = hero_module.get_stat
    local tiers = {}  -- { tier = i, list = ... } in tier order
local get_scalings = hero_module.get_scalings
    local upgrades = ability["Upgrades"]
local has_stat     = hero_module.has_stat
     if type(upgrades) == "table" then
        -- ipairs rather than #: mw.loadJsonData tables have no length.
        for i, tier in ipairs(upgrades) do
            if type(tier) == "table" then
                table.insert(tiers, { tier = i, list = spirit_scalings(tier, labels) })
            end
        end
     end


local has_override_or_stat, stat_number
    -- Names used by more than one scaling prop of this ability get their Title
    -- appended so the rows can be told apart.
    local keys_by_name = {}
    local function note_keys(list)
        for _, prop in ipairs(list) do
            local info = labels[prop.key]
            if info then
                keys_by_name[info.name] = keys_by_name[info.name] or {}
                keys_by_name[info.name][prop.key] = true
            end
        end
    end
    note_keys(base)
    for _, t in ipairs(tiers) do note_keys(t.list) end


--------------------------------------------------------------------------------
    local function label_of(key)
-- CONFIG
        local info = labels[key]
--------------------------------------------------------------------------------
        if not info then return key end
        local n = 0
        for _ in pairs(keys_by_name[info.name] or {}) do n = n + 1 end
        if n > 1 and info.title then return info.name .. ": " .. info.title end
        return info.name
    end


local DEFAULT_HERO_KEY = 'hero_targetdummy'  -- used when |key= is missing/unknown
    local cells  = {}
local DEFAULT_SIG_FIGS = 3
    local running = {}  -- prop key -> current total scaling
local FALLBACK_CARD    = 'Dummy card.png'


-- Row fields (all optional except a label):
    if #base > 0 then
--  stat        Key in Data:HeroData.json. Resolved top-level, then .Weapon,
        local lines = {}
--              then .Weapon.AltFire. Omit for manual-input-only rows.
        for _, prop in ipairs(base) do
--  param      |param= that overrides the value.
            table.insert(lines, format_spirit_badge(prop.value) .. " (" .. label_of(prop.key) .. ")")
--  label      Key in Data:Lang_<code>.json.
            running[prop.key] = prop.value
--  label_dict  Key in Data:Dictionary. Used instead of `label`.
        end
--  label_text  Literal, untranslated label. Used instead of `label`.
        cells["Spirit Scaling"] = table.concat(lines, "<br>")
--  link        Target for the label, as 'Page' or 'Page#Anchor'. The language
    end
--              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 = {
    local notes = {}
{
    for _, t in ipairs(tiers) do
class  = 'infobox-h-dropdown-weapon',
        local i, list = t.tier, t.list
header = 'CitadelHeroStats_Weapon',
        local groups, group_order = {}, {}  -- Multiply upgrades, grouped by multiplier
link  = 'Stats#Weapon',
        for _, up in ipairs(list) do
color  = 'Brown',
            local label  = label_of(up.key)
rows = {
            local current = running[up.key]
{
            if up.multiply then
stat    = 'DPS',
                local group = groups[up.value]
param  = 'dps',
                if not group then
label  = 'DPS_label',
                    group = { labels = {}, totals = {} }
link    = 'Damage per second',
                    groups[up.value] = group
icon    = 'Damage.png',
                    table.insert(group_order, up.value)
-- DPS is derived from bullet damage and fire rate, both of which
                end
-- show their own scaling; repeating it here would double-count.
                table.insert(group.labels, label)
scaling = false,
                if current then
},
                    running[up.key] = current * up.value
{
                    table.insert(group.totals, "×" .. format_spirit(running[up.key]))
stat      = 'BulletDamage',
                else
param      = 'bullet_damage',
                    table.insert(group.totals, "?")
label      = 'StatDesc_BulletDamage',
                end
link      = 'Bullet Damage',
            elseif current then
icon      = 'Bullet damage.png',
                running[up.key] = current + up.value
cell_class = 'infobox-h-stat-value-padded',
                table.insert(notes, "T" .. i .. " upgrade adds +×" .. format_spirit(up.value)
},
                    .. " " .. label .. " scaling (total ×" .. format_spirit(running[up.key]) .. ")")
{
            else
stat      = 'BulletsPerShot',
                running[up.key] = up.value
param    = 'pellets_per_shot',
                table.insert(notes, "T" .. i .. " upgrade grants ×" .. format_spirit(up.value)
label     = 'BonusPelletsPerShot_label',
                    .. " " .. label .. " scaling")
link      = 'Damage per second',
            end
icon      = 'Bullets per sec icon.png',
        end
icon_link = 'Fire Rate',
        for _, m in ipairs(group_order) do
show      = function(ctx) return ctx.pellets > 1 end,
            local group = groups[m]
},
            table.insert(notes, "T" .. i .. " upgrade multiplies " .. join_names(group.labels)
{
                .. " scaling by " .. format_spirit(m)
stat      = 'BulletsPerBurst',
                .. " (total " .. table.concat(group.totals, ", ") .. ")")
param      = 'bullets_per_burst',
        end
label_dict = 'BulletsPerBurst',
     end
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.
    if #notes > 0 then cells["Notes"] = table.concat(notes, ". ") end
local INFO_ROWS = {
     return cells
{ param = 'pronouns',     label_dict = 'Pronouns' },
end
{ 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.
-- get_cells(ability, cardprops) -> { [column header] = cell text, Notes = ... }
local CATEGORIES = {
-- `cardprops` maps the ability's prop keys to their display labels; most
{ name = 'Heroes' },
-- renderers ignore it.
{
local extra_columns = {
name = function(ctx) return ctx.hero_en end,
    ["barrier"] = {
-- Silver (Transformed) is documented on Silver's page and shares her category.
        headers  = { "Barrier" },
when = function(ctx) return ctx.hero_en ~= 'Silver (Transformed)' end,
        get_cells = Renderers.RenderBarrier,
},
    },
{
    ["bossdmgscale"] = {
name = 'Heroes with spreadshot weapons',
        headers  = { "Damage to Objectives" },
when = function(ctx) return ctx.pellets > 1 end,
        get_cells = Renderers.RenderBossDamageScale,
},
    },
{
    ["charges"] = {
name = 'Heroes with single damage spreadshot weapons',
        headers  = { "Charges", "Time Between Charges", "Notes" },
when = function(ctx)
        get_cells = Renderers.RenderCharges,
return ctx.pellets > 1 and get_stat(ctx.hero, 'HitOnceAcrossAllBullets') == true
    },
end,
    ["melee"] = {
},
        headers  = { "Base Damage", "Light Melee Scaling", "Notes" },
{
        get_cells = Renderers.RenderMelee,
name = 'Heroes with burst weapons',
    },
when = function(ctx) return ctx.burst > 1 end,
    ["heavymelee"] = {
},
        headers  = { "Base Damage", "Heavy Melee Scaling", "Notes" },
{
        get_cells = Renderers.RenderHeavyMelee,
name = 'Heroes with automatic weapons',
    },
when = function(ctx) return ctx.pellets <= 1 and ctx.burst <= 1 end,
    ["scalingspirit"] = {
},
        headers  = { "Spirit Scaling", "Notes" },
{
        get_cells = p.RenderSpiritScaling,   -- TEST: inlined copy above
name = 'Heroes with alternate fire weapons',
    },
when = function(ctx)
    ["healreduce"] = {
return ctx.hero.Weapon ~= nil and ctx.hero.Weapon.AltFire ~= nil
        headers  = { "Heal Reduction", "Notes" },
end,
        get_cells = Renderers.RenderHealReduce,
},
    },
{
    ["moveslow"] = {
name = 'Heroes with single reload weapons',
        headers  = { "Move Slow", "Notes" },
when = function(ctx) return get_stat(ctx.hero, 'ReloadSingle') == true end,
        get_cells = Renderers.RenderMoveSlow,
},
    },
{
    ["dashslow"] = {
name = 'Heroes with spin up weapons',
        headers  = { "Dash Slow", "Notes" },
when = function(ctx) return has_stat(ctx.hero, 'SpinAcceleration') end,
        get_cells = Renderers.RenderDashSlow,
},
    },
{
    ["extradebuffresist"] = {
name = 'Heroes with no weapon damage falloff',
        headers  = { "Debuff Resist" },
when = function(ctx)
        get_cells = Renderers.RenderExtraDebuffResist,
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
-- Note enrichment
--------------------------------------------------------------------------------
-- ============================================================


has_override_or_stat = function(ctx, param, stat)
-- Merges auto-generated and human notes for full-table mode.
if param and ctx.args[param] ~= '' then return true end
-- Capitalizes human note, joins with ". ", ensures trailing period.
return has_stat(ctx.hero, stat)
local function enrich_notes_full(extra, human_note)
    if human_note then
        human_note = human_note:sub(1,1):upper() .. human_note:sub(2)
        extra["Notes"] = extra["Notes"] and (extra["Notes"] .. ". " .. human_note) or human_note
    end
    if extra["Notes"] and extra["Notes"]:sub(-1) ~= "." then
        extra["Notes"] = extra["Notes"] .. "."
    end
end
end


-- Rounded numeric value of a stat, honouring a |param= override.
-- Merges auto-generated and human notes for compact mode.
stat_number = function(ctx, stat, param)
-- Decapitalizes human note (skips T{N} prefixes like "T1 slow"),
if param and ctx.args[param] ~= '' then return tonumber(ctx.args[param]) or 0 end
-- joins with "; ", strips trailing period.
return tonumber(util.round_to_sig_fig(get_stat(ctx.hero, stat) or 0, DEFAULT_SIG_FIGS)) or 0
local function enrich_notes_compact(extra, human_note)
    if human_note then
        local first = human_note:sub(1,1)
        if first:match("%u") and not human_note:sub(2,2):match("%d") then
            human_note = first:lower() .. human_note:sub(2)
        end
        if human_note:sub(-1) == "." then human_note = human_note:sub(1, -2) end
        extra["Notes"] = extra["Notes"] and (extra["Notes"] .. "; " .. human_note) or human_note
    end
end
end


local function format_value(raw, sig_figs)
-- ============================================================
if raw == nil then return '0' end
-- Ability views
local rounded = util.round_to_sig_fig(raw, sig_figs or DEFAULT_SIG_FIGS)
--
return tostring(rounded ~= nil and rounded or raw)
-- In-game Ability may containe 1 or more records in AbilityData.json
end
-- Example: Vexing Bolt is 2 records (Launch Bolt + Redirect Bolt).
 
-- "Ability View" is a main-record + any relevant sub-ability records.
-- Each view entry also carries "cardprops", the display labels of the
-- ability's props taken from its card (see card_prop_labels). They are kept
-- beside the record rather than inside it so that membership matching, which
-- walks the whole record, never sees them.
-- ============================================================
 
-- Display labels for a card's props: prop key -> { name, title, order }, read
-- from the Info sections of the card in AbilityCards.json. The flat
-- AbilityData records carry no display names, and a key such as "Damage" is
-- reused across abilities under different labels, so the map is built per
-- card rather than wiki-wide. `order` is the position of the prop on the
-- card, so renderers can list props the way the in-game card does.
local function card_prop_labels(card)
    local labels, n = {}, 0


--------------------------------------------------------------------------------
    local function walk(node)
-- Rendering helpers
        if type(node) ~= "table" then return end
--------------------------------------------------------------------------------
        local key, name = node["Key"], node["Name"]
        if type(key) == "string" and type(name) == "string" and not labels[key] then
            n = n + 1
            local title = node["Title"]
            if type(title) == "string" then
                title = title:gsub(":%s*$", "")
            else
                title = nil
            end
            labels[key] = { name = name, title = title, order = n }
        end
        if node[1] ~= nil then
            for _, child in ipairs(node) do walk(child) end
        else
            -- Sections such as Range or Move key each prop by its name:
            -- { AttackDashRange = { Name = "...", Value = ... } }.
            local keyed = {}
            for k, v in pairs(node) do
                if type(k) == "string" and type(v) == "table" and v["Key"] == nil
                    and type(v["Name"]) == "string" and not labels[k]
                then
                    table.insert(keyed, k)
                end
            end
            table.sort(keyed)
            for _, k in ipairs(keyed) do
                n = n + 1
                labels[k] = { name = node[k]["Name"], order = n }
            end
            -- Main props before Alt props, then anything else in key order.
            if node["Main"] ~= nil then walk(node["Main"]) end
            if node["Alt"]  ~= nil then walk(node["Alt"])  end
            local rest = {}
            for k, v in pairs(node) do
                if k ~= "Main" and k ~= "Alt" and k ~= "Upgrades" and type(v) == "table" then
                    table.insert(rest, k)
                end
            end
            table.sort(rest, function(a, b) return tostring(a) < tostring(b) end)
            for _, k in ipairs(rest) do walk(node[k]) end
        end
    end


local function theme_var(hero_key, name)
    for _, section in ipairs({ "Info1", "Info2", "Info3" }) do
local hero_theme = theme_data[hero_key]
        walk(card[section])
if hero_theme and hero_theme[name] ~= nil then return hero_theme[name] end
    end
return theme_data['hero_default'][name]
    local rest = {}
end
    for k, v in pairs(card) do
        if type(v) == "table" and k ~= "Upgrades"
            and k ~= "Info1" and k ~= "Info2" and k ~= "Info3"
        then
            table.insert(rest, k)
        end
    end
    table.sort(rest, function(a, b) return tostring(a) < tostring(b) end)
    for _, k in ipairs(rest) do walk(card[k]) end


local function label_of(row)
    -- Header attributes are stored on the card without a Name.
if row.label_text then return row.label_text end
    local header_attrs = {
if row.label_dict then return dict_module.translate(row.label_dict) end
        { "AbilityCastRange",            "Cast Range" },
return lang_module.get_string(row.label)
        { "Radius",                      "Radius" },
end
        { "AbilityDuration",            "Duration" },
        { "AbilityChannelTime",          "Channel Time" },
        { "AbilityCooldown",            "Cooldown" },
        { "AbilityCharges",              "Charges" },
        { "AbilityCooldownBetweenCharge", "Charge Cooldown" },
    }
    for _, attr in ipairs(header_attrs) do
        if not labels[attr[1]] then
            n = n + 1
            labels[attr[1]] = { name = attr[2], order = n }
        end
    end


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


local function icon_cell(ctx, row, color, value_html)
local _views = nil  -- card key -> { view = record, heroName = ..., abilityNum = ..., cardprops = ... }
local icon = ''
local _order = nil  -- array of card keys, sorted by hero then slot
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 function build_views()
local NO_AMMO_STATS = { ClipSize = true, ReloadTime = true }
    if _views then return _views, _order end
    _views, _order = {}, {}


-- Returns true when the row rendered.
    local hero_data = mw.loadJsonData("Data:HeroData.json")
local function render_stat_row(ctx, tbl, row, color, is_first)
    local active_heroes = {}
if row.show and not row.show(ctx) then return false end
    for _, hero in pairs(hero_data) do
if row.optional and not has_override_or_stat(ctx, row.param, row.stat) then return false end
        if type(hero) == "table"
            and hero["IsDisabled"] == false
            and type(hero["Name"]) == "string"
            and (hero["IsSelectable"] == nil or hero["IsSelectable"] ~= false)
        then
            active_heroes[hero["Name"]] = true
        end
    end


local value, scaling_inner
    local ability_data = mw.loadJsonData(GameData.Dataset.ABILITIES)
if NO_AMMO_STATS[row.stat] and ctx.args.no_ammo == 'true' then
    local cards        = mw.loadJsonData("Data:AbilityCards.json")
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 card_of = {}
local parts = {}
    for _, hero_entry in pairs(cards) do
for _, scaling in ipairs(get_scalings(ctx.hero, row.stat)) do
        if type(hero_entry) == "table" then
parts[#parts + 1] = hero_module.write_scalar_str(scaling.value, scaling.type)
            local hero_name = hero_entry["Name"]
end
            if hero_name and active_heroes[hero_name] then
scaling_inner = table.concat(parts)
                for slot_str, ability in pairs(hero_entry) do
value = value .. '<span class="infobox-h-spiritboon">' .. scaling_inner .. '</span>'
                    local slot_num = tonumber(slot_str)
else
                    local key = type(ability) == "table" and ability["Key"] or nil
scaling_inner = ''
                    if slot_num and key and ability_data[key] then
end
                        card_of[key] = { heroName = hero_name, abilityNum = slot_num, card = ability }
end
                    end
                end
            end
        end
    end


local tr = tbl:tag('tr')
    -- Attach each non-card record to the longest card key it prefixes.
    local subs = {}
    for key, record in pairs(ability_data) do
        if not card_of[key] and record["IsDisabled"] ~= true then
            local parent = nil
            for card_key in pairs(card_of) do
                if key:sub(1, #card_key + 1) == card_key .. "_"
                    and (parent == nil or #card_key > #parent)
                then
                    parent = card_key
                end
            end
            if parent then
                subs[parent] = subs[parent] or {}
                table.insert(subs[parent], record)
            end
        end
    end


local label_cell = tr:tag('td')
    for key, info in pairs(card_of) do
-- The section's first row needs extra headroom when it carries a scaling icon.
        local view = {}
if is_first and scaling_inner ~= '' then
        for k, v in pairs(ability_data[key]) do view[k] = v end
label_cell:addClass('infobox-h-stat-label-scaled')
        view["_subabilities"] = subs[key]
end
        _views[key] = {
if row.link then
            view      = view,
label_cell:wikitext(string.format('[[%s|<span class="infobox-h-stat-label">%s</span>]]:',
            heroName  = info.heroName,
page_link(ctx, row.link), label_of(row)))
            abilityNum = info.abilityNum,
else
            cardprops  = card_prop_labels(info.card),
label_cell:wikitext(label_of(row) .. ':')
        }
end
        table.insert(_order, key)
    end


local value_cell = tr:tag('td')
    table.sort(_order, function(a, b)
if row.cell_class then value_cell:addClass(row.cell_class) end
        local ia, ib = _views[a], _views[b]
value_cell:wikitext(icon_cell(ctx, row, color, value))
        if ia.heroName ~= ib.heroName then return ia.heroName < ib.heroName end
        return ia.abilityNum < ib.abilityNum
    end)


return true
    return _views, _order
end
end


local function render_section(ctx, parent, section)
-- ============================================================
local tbl = parent:tag('tr'):tag('th')
-- Membership
:attr('colspan', '2')
-- Pieces of code that help in telling  "is ability X a member of list Y".
:addClass('infobox-h-subsection infobox-h-dropdown ' .. section.class)
-- main function is: p.is_member()
:tag('table'):addClass('mw-collapsible infobox-h-subsection-child')
-- ============================================================


local caption = tbl:tag('caption'):addClass('infobox-h-dropdown-header')
-- Memoized name-exclusion set per list (from Lists.exclude_abilities).
if ctx.if_lang == '/ru' then
local _excluded = {}
caption:addClass('infobox-h-dropdown-header-overflow')
local function excluded_set(stat)
end
    if _excluded[stat] then return _excluded[stat] end
caption:wikitext(string.format(
    local set = {}
'[[%s|<span class="infotext-h-bold infobox-h-dropdown-title">%s</span>]]',
    for _, name in ipairs(Lists.exclude_abilities[stat] or {}) do
page_link(ctx, section.link), lang_module.get_string(section.header)))
        set[name] = true
    end
    _excluded[stat] = set
    return set
end


local is_first = true
-- Per-list rules that can't be expressed as prop lists or exclusions.
for _, row in ipairs(section.rows) do
-- Returns true to keep the ability, false to drop it.
if render_stat_row(ctx, tbl, row, section.color, is_first) then
local function special_ok(stat, record, ability_num)
is_first = false
    if stat == "dispelmagic" and ability_num == 4 then return false end
end
    return true
end
end
end


local function render_information(ctx, parent)
-- Public: does ability `ability_key` belong to list `stat`?
local present = {}
function p.is_member(stat, ability_key, ability_num)
for _, row in ipairs(INFO_ROWS) do
    local props = Lists.lists[stat]
if ctx.args[row.param] ~= '' then present[#present + 1] = row end
    if not props then return false end
end
 
if #present == 0 then return end
    local views = build_views()
    local entry = views[ability_key]
    local view  = entry and entry.view
 
    if not view then
        view = mw.loadJsonData(GameData.Dataset.ABILITIES)[ability_key]
        if not view then return false end
    end


parent:tag('tr'):tag('th')
    local name = view["Name"]
:attr('colspan', '2')
    if name and excluded_set(stat)[name] then return false end
: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')
    ability_num = ability_num or (entry and entry.abilityNum)
:attr('colspan', '2')
    if not special_ok(stat, view, ability_num) then return false end
:addClass('infobox-h-subsection')
:tag('table'):addClass('infobox-h-subsection-child')


for _, row in ipairs(present) do
    return GameData.entity_matches(view, props)
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
end


local function render_title(ctx, parent)
-- ============================================================
local th = parent:tag('tr'):tag('th'):attr('colspan', '2'):addClass('infobox-h-title')
-- Entry point
if ctx.args.name_font_size ~= '' then
-- ============================================================
th:cssText('font-size: ' .. ctx.args.name_font_size .. ';')
 
end
function p.render(frame)
    local stat = mw.text.trim(frame.args[1] or ""):lower()
 
    local internal_keys = Lists.lists[stat]
    if not internal_keys then
        return '<span class="error">AbilityTable: unknown list "' .. stat .. '".</span>'
    end
 
    local hide_set = {}
    local hide_param = mw.text.trim(frame.args["hide"] or "")
    if hide_param ~= "" then
        for col in hide_param:gmatch("[^,]+") do hide_set[mw.text.trim(col)] = true end
    end
 
    local compact = mw.text.trim(frame.args["compact"] or "") ~= ""
 
    local collapsed = mw.text.trim(frame.args["collapsed"] or ""):lower()
 
    local views, order = build_views()
    local spec      = extra_columns[stat]
    local headers    = spec and spec.headers or {}
    local get_cells  = spec and spec.get_cells or function() return {} end
    local stat_notes = Notes[stat]


local wrap = th:tag('div'):addClass('infobox-h-title-hero')
    local filtered = {}
wrap:tag('div'):addClass('infobox-h-title-hero-left')
    for _, key in ipairs(order) do
        if p.is_member(stat, key) then
            table.insert(filtered, key)
        end
    end


-- Name logo: <Name>_name.svg, or <Name>_name_<lang>.svg on translated pages.
    if #filtered == 0 then
-- Heroes without a logo fall back to their name as plain text.
        return "No abilities found for list: " .. stat
local svg = ((ctx.args.name ~= '' and ctx.args.name) or ctx.hero_en) .. '_name'
    end
if ctx.if_lang ~= '' then svg = svg .. '_' .. ctx.lang_code end


local middle = wrap:tag('div'):addClass('infobox-h-title-hero-middle')
    local lists_url = mw.uri.fullUrl("Module:AbilityTable/Lists")
if util.page_exists('File:' .. svg .. '.svg') then
    local notes_url = mw.uri.fullUrl("Module:AbilityTable/Notes", { action = "edit" })
middle:wikitext(ctx.frame:expandTemplate{ title = 'Icon/White', args = {
    local disclaimer = frame:preprocess(
string.format('[[File:%s.svg|%s|class=notpageimage]]',
        '<div class="ability-table-disclaimer">'
svg, theme_var(ctx.hero_key, 'logo-height')) } })
        .. 'This is a dynamic list '
else
        .. '<span class="ability-table-list-name">"' .. stat .. '"</span>. '
middle:wikitext(ctx.display_name)
        .. '[' .. tostring(lists_url) .. ' Edit contents]'
end
        .. ' · '
        .. '[' .. tostring(notes_url) .. ' Edit notes].'
        .. '</div>'
    )


local right = wrap:tag('div'):addClass('infobox-h-title-hero-right'):tag('div')
    if compact then
if ctx.show_icon then
        local hero_order    = {}
local icon = ((ctx.args.icon ~= '' and ctx.args.icon) or ctx.hero_en) .. '.png'
        local hero_abilities = {}
if util.page_exists('File:' .. icon) then
        for _, key in ipairs(filtered) do
right:tag('div'):addClass('infobox-h-title-hero-tornbox')
            local hero = views[key].heroName
right:tag('div'):addClass('infobox-h-title-hero-icon')
            if not hero_abilities[hero] then
:wikitext(string.format('[[File:%s|center|35px|class=notpageimage]]', icon))
                table.insert(hero_order, hero)
end
                hero_abilities[hero] = {}
end
            end
end
            table.insert(hero_abilities[hero], views[key])
        end


local function render_card(ctx, parent)
        local items = {}
local card = (ctx.args.card ~= '' and ctx.args.card) or (ctx.hero_en .. ' card.png')
        for _, hero in ipairs(hero_order) do
if not util.page_exists('File:' .. card) then card = FALLBACK_CARD end
            local hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { hero } }
parent:tag('tr'):tag('th')
            local ability_parts = {}
:attr('colspan', '2')
            for _, entry in ipairs(hero_abilities[hero]) do
:addClass('infobox-h-image')
                local ability = entry.view
:tag('div')
                local name = ability["Name"]
:wikitext(string.format('[[File:%s|150px|alt=%s]]',
                local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { name } }
card, mw.title.getCurrentTitle().baseText))
end


local function render_tags(ctx, parent)
                local extra = get_cells(ability, entry.cardprops)
if not ctx.show_hero_tags then return end
                enrich_notes_compact(extra, stat_notes and stat_notes[name])


local tags = {}
                local note = extra["Notes"]
for i = 1, 3 do
                if note then
local tag = hero_module.hero_tag(ctx.hero_en, i)
                    table.insert(ability_parts, ability_cell .. " (" .. note .. ")")
if tag ~= '' then
                else
tags[#tags + 1] = tag
                    table.insert(ability_parts, ability_cell)
end
                end
end
            end
if #tags == 0 then return end
            table.insert(items, "<li>" .. hero_cell .. ": " .. table.concat(ability_parts, ", ") .. "</li>")
        end


local inner = parent:tag('tr'):tag('td')
        return disclaimer .. "<ul>" .. table.concat(items) .. "</ul>"
:attr('colspan', '2')
    end
: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)
    local has_notes_header = false
if mw.ustring.lower(ctx.args.nocat ~= '' and ctx.args.nocat or 'false') ~= 'false' then
    local final_headers = {}
return ''
    for _, h in ipairs(headers) do
end
        if h == "Notes" then
if get_stat(ctx.hero, 'IsDisabled') ~= false then return '' end
            has_notes_header = true
        elseif not hide_set[h] then
            table.insert(final_headers, h)
        end
    end
    local needs_notes = (has_notes_header or stat_notes ~= nil) and not hide_set["Notes"]
    if needs_notes then table.insert(final_headers, "Notes") end


local out = {}
    local table_class = 'wikitable sortable ability-table'
for _, category in ipairs(CATEGORIES) do
    if collapsed ~= "" then
if category.when == nil or category.when(ctx) then
        table_class = table_class .. ' mw-collapsible'
local name = category.name
        if collapsed == "yes" or collapsed == "true" or collapsed == "1" then
if type(name) == 'function' then name = name(ctx) end
            table_class = table_class .. ' mw-collapsed'
out[#out + 1] = ctx.frame:expandTemplate{ title = 'lang cat', args = { name } }
        end
end
    end
end
return table.concat(out)
end


--------------------------------------------------------------------------------
    local out = {}
-- Entry point
    table.insert(out, disclaimer)
--------------------------------------------------------------------------------
    table.insert(out, '{| class="' .. table_class .. '"')


function p.render(frame)
    local header_row = '! class="ability-table-col-hero" | Hero'
local parent = frame:getParent()
                    .. ' !! class="ability-table-col-ability" | Ability'
local invoke_args = frame.args
    for _, h in ipairs(final_headers) do
local template_args = parent and parent.args or {}
        header_row = header_row .. ' !! class="ability-table-col-'
            .. h:lower():gsub("%s+", "-") .. '" | ' .. h
    end
    table.insert(out, header_row)


-- Missing parameters read as '' so callers never have to nil-check.
        local hero_counts = {}
local args = setmetatable({}, { __index = function(_, k)
    for _, key in ipairs(filtered) do
local value = invoke_args[k]
        local hero = views[key].heroName
if value == nil then value = template_args[k] end
        hero_counts[hero] = (hero_counts[hero] or 0) + 1
return value or ''
    end
end })


-- |key= accepts a hero_* key or the hero's English name; unknown values fall
    local hero_seen = {}
-- 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 = {
     for _, key in ipairs(filtered) do
frame     = frame,
        local entry   = views[key]
args      = args,
        local ability = entry.view
hero      = hero,
        local hero    = entry.heroName
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'
        local ability_cell = frame:expandTemplate{
ctx.show_icon      = args.show_icon ~= 'false'
            title = "AbilityIcon",
            args = { ability["Name"] }
        }


-- Needed by both row visibility and the category rules.
        local extra = get_cells(ability, entry.cardprops)
ctx.pellets = stat_number(ctx, 'BulletsPerShot', 'pellets_per_shot')
        enrich_notes_full(extra, stat_notes and stat_notes[ability["Name"]])
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)
        local row
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')
        if not hero_seen[hero] then
render_title(ctx, tbl)
            local hero_cell = frame:expandTemplate{
render_card(ctx, tbl)
                title = "HeroIcon",
render_tags(ctx, tbl)
                args = { hero }
for _, section in ipairs(SECTIONS) do
            }
render_section(ctx, tbl, section)
end
render_information(ctx, tbl)


if mw.title.getCurrentTitle().nsText ~= 'Template' then
            if hero_counts[hero] > 1 then
frame:callParserFunction('DISPLAYTITLE', ctx.display_name)
                row = '| rowspan="' .. hero_counts[hero] .. '" class="ability-table-cell-hero" | '
end
                    .. hero_cell
return tostring(root) .. render_categories(ctx)
                    .. ' || class="ability-table-cell-ability" | ' .. ability_cell
end
            else
                row = '| class="ability-table-cell-hero" | '
                    .. hero_cell
                    .. ' || class="ability-table-cell-ability" | ' .. ability_cell
            end


--------------------------------------------------------------------------------
            hero_seen[hero] = true
-- TEMPORARY render-diff harness - NOT part of the live module.
        else
-- {{#invoke:Sandbox/Vergir|diff}} or |diff|hero_atlas,hero_viscous
            row = '| class="ability-table-cell-ability" | ' .. ability_cell
--------------------------------------------------------------------------------
        end


-- Kept small: each render makes several expensive page_exists calls, and the
        for _, header in ipairs(final_headers) do
-- parser allows only 100 per page.
            local value = extra[header]
local SAMPLE_HEROES = {
            local cls  = "ability-table-cell-" .. header:lower():gsub("%s+", "-")
'hero_atlas',      -- burst-ish baseline, has scaling on many stats
            local content = value ~= nil and tostring(value) or "—"
'hero_viscous',    -- has an AltFire block
            row = row .. ' || class="' .. cls .. '" | ' .. content
'hero_haze',      -- spreadshot
        end
'hero_werewolf',  -- Silver, has a transformed variant
'hero_targetdummy',-- the fallback hero
}


function p.diff(frame)
        table.insert(out, "|-")
local other = require('Module:InfoboxHero')
        table.insert(out, row)
local keys = {}
    end
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, {}
    table.insert(out, "|}")
for _, hero_key in ipairs(keys) do
    return table.concat(out, "\n")
local child = frame:newChild{ args = { key = hero_key } }
end
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 ..
-- TEST ONLY: plain-text dump of a list for review, one line per ability.
'\n\nMismatches: ' .. #mismatches ..
function p.dump(frame)
(#mismatches > 0 and ('\n* ' .. table.concat(mismatches, '\n* ')) or '\n\nIdentical output.')
    local stat = mw.text.trim(frame.args[1] or ""):lower()
    local views, order = build_views()
    local spec = extra_columns[stat]
    local get_cells = spec and spec.get_cells or function() return {} end
    local out = {}
    for _, key in ipairs(order) do
        if p.is_member(stat, key) then
            local entry = views[key]
            local extra = get_cells(entry.view, entry.cardprops)
            local cell = extra["Spirit Scaling"] or "—"
            cell = mw.text.killMarkers(cell):gsub("<br>", " / "):gsub("<[^>]+>", "")
            table.insert(out, entry.heroName .. " | " .. entry.view["Name"] .. " | " .. cell .. " | " .. (extra["Notes"] or "—"))
        end
    end
    return "<pre>" .. mw.text.nowiki(table.concat(out, "\n")) .. "</pre>"
end
end


return p
return p

Latest revision as of 23:04, 13 September 2026

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

-- Renders sortable wikitables of abilities filtered by a named stat.

local Lists     = require("Module:AbilityTable/Lists")
local Notes     = require("Module:AbilityTable/Notes")
local Renderers = require("Module:AbilityTable/ComplexRenderers")
local GameData  = require("Module:GameData")

local p = {}

-- ============================================================
-- TEST COPY of Module:AbilityTable with the proposed
-- Module:AbilityTable/ComplexRenderers.RenderSpiritScaling inlined below,
-- so the whole change can be previewed from one sandbox module.
-- ============================================================

-- ============================================================
-- Spirit scaling helpers
-- ============================================================

-- Returns the Spirit scaling entry of a prop value, or nil. A prop's Scale is
-- either a single { Value, Type } table or an array of them (a duration that
-- scales with both Spirit and ability duration, for example).
local function spirit_scale(v)
    if type(v) ~= "table" then return nil end
    local scale = v["Scale"]
    if type(scale) ~= "table" then return nil end
    if scale[1] == nil then
        if scale["Type"] == "spirit" then return scale end
        return nil
    end
    for _, entry in ipairs(scale) do
        if type(entry) == "table" and entry["Type"] == "spirit" then return entry end
    end
    return nil
end

-- Collects the non-zero Spirit scalings of one record layer (the base record
-- or one upgrade tier) as { key, value, multiply }, ordered the way the
-- ability card lists its props; props the card does not list come last, in
-- key order. `labels` is the view's cardprops map (see Module:AbilityTable).
local function spirit_scalings(layer, labels)
    local out = {}
    for k, v in pairs(layer) do
        if k ~= "Upgrades" and tostring(k):sub(1, 1) ~= "_" then
            local s = spirit_scale(v)
            if s and type(s["Value"]) == "number" and s["Value"] ~= 0 then
                table.insert(out, {
                    key      = k,
                    value    = s["Value"],
                    multiply = s["Multiply"] == true,
                })
            end
        end
    end
    table.sort(out, function(a, b)
        local oa = labels[a.key] and labels[a.key].order or math.huge
        local ob = labels[b.key] and labels[b.key].order or math.huge
        if oa ~= ob then return oa < ob end
        return a.key < b.key
    end)
    return out
end

-- Formats a scaling value to 3 significant figures, as the ability cards do.
local function format_spirit(v)
    return string.format("%.3g", v)
end

-- Formats one Spirit scaling for the table cell through the compact {{Ss}}
-- form, the same way RenderBarrier does. The template is expanded via the
-- frame the surrounding #invoke runs under, since module return values are
-- not re-expanded; the plain "×N" fallback covers a nil frame (module
-- console).
local function format_spirit_badge(v)
    local text  = format_spirit(v)
    local frame = mw.getCurrentFrame()
    if frame then
        return frame:expandTemplate{
            title = "Ss",
            args  = { text, compact = "1", show_value = "1" },
        }
    end
    return "×" .. text
end

-- "A", "A and B", "A, B and C".
local function join_names(names)
    if #names <= 1 then return names[1] or "" end
    return table.concat(names, ", ", 1, #names - 1) .. " and " .. names[#names]
end

-- Renders Spirit Power scaling.
-- Lists every prop that scales with Spirit at base, one per line as an {{Ss}}
-- badge labelled with the prop's name from the ability card:
-- "x0.55 (Base Damage)". Two
-- props sharing a name are told apart by the card's Title
-- ("Damage: Cost of Stay"); props the card does not list show their raw key.
-- Upgrades are reported in Notes per prop, with the running total: additive
-- ones as "adds +×0.5 ... (total ×1.05)", ones that unlock scaling on a prop
-- as "grants ×1.3 ...", and Multiply upgrades as "multiplies ... by 1.15",
-- which is how the ability-upgrade gadget applies them.
function p.RenderSpiritScaling(ability, cardprops)
    local labels = cardprops or {}

    local base  = spirit_scalings(ability, labels)
    local tiers = {}   -- { tier = i, list = ... } in tier order
    local upgrades = ability["Upgrades"]
    if type(upgrades) == "table" then
        -- ipairs rather than #: mw.loadJsonData tables have no length.
        for i, tier in ipairs(upgrades) do
            if type(tier) == "table" then
                table.insert(tiers, { tier = i, list = spirit_scalings(tier, labels) })
            end
        end
    end

    -- Names used by more than one scaling prop of this ability get their Title
    -- appended so the rows can be told apart.
    local keys_by_name = {}
    local function note_keys(list)
        for _, prop in ipairs(list) do
            local info = labels[prop.key]
            if info then
                keys_by_name[info.name] = keys_by_name[info.name] or {}
                keys_by_name[info.name][prop.key] = true
            end
        end
    end
    note_keys(base)
    for _, t in ipairs(tiers) do note_keys(t.list) end

    local function label_of(key)
        local info = labels[key]
        if not info then return key end
        local n = 0
        for _ in pairs(keys_by_name[info.name] or {}) do n = n + 1 end
        if n > 1 and info.title then return info.name .. ": " .. info.title end
        return info.name
    end

    local cells   = {}
    local running = {}   -- prop key -> current total scaling

    if #base > 0 then
        local lines = {}
        for _, prop in ipairs(base) do
            table.insert(lines, format_spirit_badge(prop.value) .. " (" .. label_of(prop.key) .. ")")
            running[prop.key] = prop.value
        end
        cells["Spirit Scaling"] = table.concat(lines, "<br>")
    end

    local notes = {}
    for _, t in ipairs(tiers) do
        local i, list = t.tier, t.list
        local groups, group_order = {}, {}   -- Multiply upgrades, grouped by multiplier
        for _, up in ipairs(list) do
            local label   = label_of(up.key)
            local current = running[up.key]
            if up.multiply then
                local group = groups[up.value]
                if not group then
                    group = { labels = {}, totals = {} }
                    groups[up.value] = group
                    table.insert(group_order, up.value)
                end
                table.insert(group.labels, label)
                if current then
                    running[up.key] = current * up.value
                    table.insert(group.totals, "×" .. format_spirit(running[up.key]))
                else
                    table.insert(group.totals, "?")
                end
            elseif current then
                running[up.key] = current + up.value
                table.insert(notes, "T" .. i .. " upgrade adds +×" .. format_spirit(up.value)
                    .. " " .. label .. " scaling (total ×" .. format_spirit(running[up.key]) .. ")")
            else
                running[up.key] = up.value
                table.insert(notes, "T" .. i .. " upgrade grants ×" .. format_spirit(up.value)
                    .. " " .. label .. " scaling")
            end
        end
        for _, m in ipairs(group_order) do
            local group = groups[m]
            table.insert(notes, "T" .. i .. " upgrade multiplies " .. join_names(group.labels)
                .. " scaling by " .. format_spirit(m)
                .. " (total " .. table.concat(group.totals, ", ") .. ")")
        end
    end

    if #notes > 0 then cells["Notes"] = table.concat(notes, ". ") end
    return cells
end

-- get_cells(ability, cardprops) -> { [column header] = cell text, Notes = ... }
-- `cardprops` maps the ability's prop keys to their display labels; most
-- renderers ignore it.
local extra_columns = {
    ["barrier"] = {
        headers   = { "Barrier" },
        get_cells = Renderers.RenderBarrier,
    },
    ["bossdmgscale"] = {
        headers   = { "Damage to Objectives" },
        get_cells = Renderers.RenderBossDamageScale,
    },
    ["charges"] = {
        headers   = { "Charges", "Time Between Charges", "Notes" },
        get_cells = Renderers.RenderCharges,
    },
    ["melee"] = {
        headers   = { "Base Damage", "Light Melee Scaling", "Notes" },
        get_cells = Renderers.RenderMelee,
    },
    ["heavymelee"] = {
        headers   = { "Base Damage", "Heavy Melee Scaling", "Notes" },
        get_cells = Renderers.RenderHeavyMelee,
    },
    ["scalingspirit"] = {
        headers   = { "Spirit Scaling", "Notes" },
        get_cells = p.RenderSpiritScaling,   -- TEST: inlined copy above
    },
    ["healreduce"] = {
        headers   = { "Heal Reduction", "Notes" },
        get_cells = Renderers.RenderHealReduce,
    },
    ["moveslow"] = {
        headers   = { "Move Slow", "Notes" },
        get_cells = Renderers.RenderMoveSlow,
    },
    ["dashslow"] = {
        headers   = { "Dash Slow", "Notes" },
        get_cells = Renderers.RenderDashSlow,
    },
    ["extradebuffresist"] = {
        headers   = { "Debuff Resist" },
        get_cells = Renderers.RenderExtraDebuffResist,
    },
}

-- ============================================================
-- Note enrichment
-- ============================================================

-- Merges auto-generated and human notes for full-table mode.
-- Capitalizes human note, joins with ". ", ensures trailing period.
local function enrich_notes_full(extra, human_note)
    if human_note then
        human_note = human_note:sub(1,1):upper() .. human_note:sub(2)
        extra["Notes"] = extra["Notes"] and (extra["Notes"] .. ". " .. human_note) or human_note
    end
    if extra["Notes"] and extra["Notes"]:sub(-1) ~= "." then
        extra["Notes"] = extra["Notes"] .. "."
    end
end

-- Merges auto-generated and human notes for compact mode.
-- Decapitalizes human note (skips T{N} prefixes like "T1 slow"),
-- joins with "; ", strips trailing period.
local function enrich_notes_compact(extra, human_note)
    if human_note then
        local first = human_note:sub(1,1)
        if first:match("%u") and not human_note:sub(2,2):match("%d") then
            human_note = first:lower() .. human_note:sub(2)
        end
        if human_note:sub(-1) == "." then human_note = human_note:sub(1, -2) end
        extra["Notes"] = extra["Notes"] and (extra["Notes"] .. "; " .. human_note) or human_note
    end
end

-- ============================================================
-- Ability views
--
-- In-game Ability may containe 1 or more records in AbilityData.json
-- Example: Vexing Bolt is 2 records (Launch Bolt + Redirect Bolt).

-- "Ability View" is a main-record + any relevant sub-ability records.
-- Each view entry also carries "cardprops", the display labels of the
-- ability's props taken from its card (see card_prop_labels). They are kept
-- beside the record rather than inside it so that membership matching, which
-- walks the whole record, never sees them.
-- ============================================================

-- Display labels for a card's props: prop key -> { name, title, order }, read
-- from the Info sections of the card in AbilityCards.json. The flat
-- AbilityData records carry no display names, and a key such as "Damage" is
-- reused across abilities under different labels, so the map is built per
-- card rather than wiki-wide. `order` is the position of the prop on the
-- card, so renderers can list props the way the in-game card does.
local function card_prop_labels(card)
    local labels, n = {}, 0

    local function walk(node)
        if type(node) ~= "table" then return end
        local key, name = node["Key"], node["Name"]
        if type(key) == "string" and type(name) == "string" and not labels[key] then
            n = n + 1
            local title = node["Title"]
            if type(title) == "string" then
                title = title:gsub(":%s*$", "")
            else
                title = nil
            end
            labels[key] = { name = name, title = title, order = n }
        end
        if node[1] ~= nil then
            for _, child in ipairs(node) do walk(child) end
        else
            -- Sections such as Range or Move key each prop by its name:
            -- { AttackDashRange = { Name = "...", Value = ... } }.
            local keyed = {}
            for k, v in pairs(node) do
                if type(k) == "string" and type(v) == "table" and v["Key"] == nil
                    and type(v["Name"]) == "string" and not labels[k]
                then
                    table.insert(keyed, k)
                end
            end
            table.sort(keyed)
            for _, k in ipairs(keyed) do
                n = n + 1
                labels[k] = { name = node[k]["Name"], order = n }
            end
            -- Main props before Alt props, then anything else in key order.
            if node["Main"] ~= nil then walk(node["Main"]) end
            if node["Alt"]  ~= nil then walk(node["Alt"])  end
            local rest = {}
            for k, v in pairs(node) do
                if k ~= "Main" and k ~= "Alt" and k ~= "Upgrades" and type(v) == "table" then
                    table.insert(rest, k)
                end
            end
            table.sort(rest, function(a, b) return tostring(a) < tostring(b) end)
            for _, k in ipairs(rest) do walk(node[k]) end
        end
    end

    for _, section in ipairs({ "Info1", "Info2", "Info3" }) do
        walk(card[section])
    end
    local rest = {}
    for k, v in pairs(card) do
        if type(v) == "table" and k ~= "Upgrades"
            and k ~= "Info1" and k ~= "Info2" and k ~= "Info3"
        then
            table.insert(rest, k)
        end
    end
    table.sort(rest, function(a, b) return tostring(a) < tostring(b) end)
    for _, k in ipairs(rest) do walk(card[k]) end

    -- Header attributes are stored on the card without a Name.
    local header_attrs = {
        { "AbilityCastRange",            "Cast Range" },
        { "Radius",                      "Radius" },
        { "AbilityDuration",             "Duration" },
        { "AbilityChannelTime",          "Channel Time" },
        { "AbilityCooldown",             "Cooldown" },
        { "AbilityCharges",              "Charges" },
        { "AbilityCooldownBetweenCharge", "Charge Cooldown" },
    }
    for _, attr in ipairs(header_attrs) do
        if not labels[attr[1]] then
            n = n + 1
            labels[attr[1]] = { name = attr[2], order = n }
        end
    end

    return labels
end

local _views = nil   -- card key -> { view = record, heroName = ..., abilityNum = ..., cardprops = ... }
local _order = nil   -- array of card keys, sorted by hero then slot

local function build_views()
    if _views then return _views, _order end
    _views, _order = {}, {}

    local hero_data = mw.loadJsonData("Data:HeroData.json")
    local active_heroes = {}
    for _, hero in pairs(hero_data) do
        if type(hero) == "table"
            and hero["IsDisabled"] == false
            and type(hero["Name"]) == "string"
            and (hero["IsSelectable"] == nil or hero["IsSelectable"] ~= false)
        then
            active_heroes[hero["Name"]] = true
        end
    end

    local ability_data = mw.loadJsonData(GameData.Dataset.ABILITIES)
    local cards        = mw.loadJsonData("Data:AbilityCards.json")

    local card_of = {}
    for _, hero_entry in pairs(cards) do
        if type(hero_entry) == "table" then
            local hero_name = hero_entry["Name"]
            if hero_name and active_heroes[hero_name] then
                for slot_str, ability in pairs(hero_entry) do
                    local slot_num = tonumber(slot_str)
                    local key = type(ability) == "table" and ability["Key"] or nil
                    if slot_num and key and ability_data[key] then
                        card_of[key] = { heroName = hero_name, abilityNum = slot_num, card = ability }
                    end
                end
            end
        end
    end

    -- Attach each non-card record to the longest card key it prefixes.
    local subs = {}
    for key, record in pairs(ability_data) do
        if not card_of[key] and record["IsDisabled"] ~= true then
            local parent = nil
            for card_key in pairs(card_of) do
                if key:sub(1, #card_key + 1) == card_key .. "_"
                    and (parent == nil or #card_key > #parent)
                then
                    parent = card_key
                end
            end
            if parent then
                subs[parent] = subs[parent] or {}
                table.insert(subs[parent], record)
            end
        end
    end

    for key, info in pairs(card_of) do
        local view = {}
        for k, v in pairs(ability_data[key]) do view[k] = v end
        view["_subabilities"] = subs[key]
        _views[key] = {
            view       = view,
            heroName   = info.heroName,
            abilityNum = info.abilityNum,
            cardprops  = card_prop_labels(info.card),
        }
        table.insert(_order, key)
    end

    table.sort(_order, function(a, b)
        local ia, ib = _views[a], _views[b]
        if ia.heroName ~= ib.heroName then return ia.heroName < ib.heroName end
        return ia.abilityNum < ib.abilityNum
    end)

    return _views, _order
end

-- ============================================================
-- Membership
-- Pieces of code that help in telling  "is ability X a member of list Y".
-- main function is: p.is_member()
-- ============================================================

-- Memoized name-exclusion set per list (from Lists.exclude_abilities).
local _excluded = {}
local function excluded_set(stat)
    if _excluded[stat] then return _excluded[stat] end
    local set = {}
    for _, name in ipairs(Lists.exclude_abilities[stat] or {}) do
        set[name] = true
    end
    _excluded[stat] = set
    return set
end

-- Per-list rules that can't be expressed as prop lists or exclusions.
-- Returns true to keep the ability, false to drop it.
local function special_ok(stat, record, ability_num)
    if stat == "dispelmagic" and ability_num == 4 then return false end
    return true
end

-- Public: does ability `ability_key` belong to list `stat`?
function p.is_member(stat, ability_key, ability_num)
    local props = Lists.lists[stat]
    if not props then return false end

    local views = build_views()
    local entry = views[ability_key]
    local view  = entry and entry.view

    if not view then
        view = mw.loadJsonData(GameData.Dataset.ABILITIES)[ability_key]
        if not view then return false end
    end

    local name = view["Name"]
    if name and excluded_set(stat)[name] then return false end

    ability_num = ability_num or (entry and entry.abilityNum)
    if not special_ok(stat, view, ability_num) then return false end

    return GameData.entity_matches(view, props)
end

-- ============================================================
-- Entry point
-- ============================================================

function p.render(frame)
    local stat = mw.text.trim(frame.args[1] or ""):lower()

    local internal_keys = Lists.lists[stat]
    if not internal_keys then
        return '<span class="error">AbilityTable: unknown list "' .. stat .. '".</span>'
    end

    local hide_set = {}
    local hide_param = mw.text.trim(frame.args["hide"] or "")
    if hide_param ~= "" then
        for col in hide_param:gmatch("[^,]+") do hide_set[mw.text.trim(col)] = true end
    end

    local compact = mw.text.trim(frame.args["compact"] or "") ~= ""

    local collapsed = mw.text.trim(frame.args["collapsed"] or ""):lower()

    local views, order = build_views()
    local spec       = extra_columns[stat]
    local headers    = spec and spec.headers or {}
    local get_cells  = spec and spec.get_cells or function() return {} end
    local stat_notes = Notes[stat]

    local filtered = {}
    for _, key in ipairs(order) do
        if p.is_member(stat, key) then
            table.insert(filtered, key)
        end
    end

    if #filtered == 0 then
        return "No abilities found for list: " .. stat
    end

    local lists_url = mw.uri.fullUrl("Module:AbilityTable/Lists")
    local notes_url = mw.uri.fullUrl("Module:AbilityTable/Notes", { action = "edit" })
    local disclaimer = frame:preprocess(
        '<div class="ability-table-disclaimer">'
        .. 'This is a dynamic list '
        .. '<span class="ability-table-list-name">"' .. stat .. '"</span>. '
        .. '[' .. tostring(lists_url) .. ' Edit contents]'
        .. ' · '
        .. '[' .. tostring(notes_url) .. ' Edit notes].'
        .. '</div>'
    )

    if compact then
        local hero_order     = {}
        local hero_abilities = {}
        for _, key in ipairs(filtered) do
            local hero = views[key].heroName
            if not hero_abilities[hero] then
                table.insert(hero_order, hero)
                hero_abilities[hero] = {}
            end
            table.insert(hero_abilities[hero], views[key])
        end

        local items = {}
        for _, hero in ipairs(hero_order) do
            local hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { hero } }
            local ability_parts = {}
            for _, entry in ipairs(hero_abilities[hero]) do
                local ability = entry.view
                local name = ability["Name"]
                local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { name } }

                local extra = get_cells(ability, entry.cardprops)
                enrich_notes_compact(extra, stat_notes and stat_notes[name])

                local note = extra["Notes"]
                if note then
                    table.insert(ability_parts, ability_cell .. " (" .. note .. ")")
                else
                    table.insert(ability_parts, ability_cell)
                end
            end
            table.insert(items, "<li>" .. hero_cell .. ": " .. table.concat(ability_parts, ", ") .. "</li>")
        end

        return disclaimer .. "<ul>" .. table.concat(items) .. "</ul>"
    end

    local has_notes_header = false
    local final_headers = {}
    for _, h in ipairs(headers) do
        if h == "Notes" then
            has_notes_header = true
        elseif not hide_set[h] then
            table.insert(final_headers, h)
        end
    end
    local needs_notes = (has_notes_header or stat_notes ~= nil) and not hide_set["Notes"]
    if needs_notes then table.insert(final_headers, "Notes") end

    local table_class = 'wikitable sortable ability-table'
    if collapsed ~= "" then
        table_class = table_class .. ' mw-collapsible'
        if collapsed == "yes" or collapsed == "true" or collapsed == "1" then
            table_class = table_class .. ' mw-collapsed'
        end
    end

    local out = {}
    table.insert(out, disclaimer)
    table.insert(out, '{| class="' .. table_class .. '"')

    local header_row = '! class="ability-table-col-hero" | Hero'
                    .. ' !! class="ability-table-col-ability" | Ability'
    for _, h in ipairs(final_headers) do
        header_row = header_row .. ' !! class="ability-table-col-'
            .. h:lower():gsub("%s+", "-") .. '" | ' .. h
    end
    table.insert(out, header_row)

        local hero_counts = {}
    for _, key in ipairs(filtered) do
        local hero = views[key].heroName
        hero_counts[hero] = (hero_counts[hero] or 0) + 1
    end

    local hero_seen = {}

    for _, key in ipairs(filtered) do
        local entry   = views[key]
        local ability = entry.view
        local hero    = entry.heroName

        local ability_cell = frame:expandTemplate{
            title = "AbilityIcon",
            args = { ability["Name"] }
        }

        local extra = get_cells(ability, entry.cardprops)
        enrich_notes_full(extra, stat_notes and stat_notes[ability["Name"]])

        local row

        if not hero_seen[hero] then
            local hero_cell = frame:expandTemplate{
                title = "HeroIcon",
                args = { hero }
            }

            if hero_counts[hero] > 1 then
                row = '| rowspan="' .. hero_counts[hero] .. '" class="ability-table-cell-hero" | '
                    .. hero_cell
                    .. ' || class="ability-table-cell-ability" | ' .. ability_cell
            else
                row = '| class="ability-table-cell-hero" | '
                    .. hero_cell
                    .. ' || class="ability-table-cell-ability" | ' .. ability_cell
            end

            hero_seen[hero] = true
        else
            row = '| class="ability-table-cell-ability" | ' .. ability_cell
        end

        for _, header in ipairs(final_headers) do
            local value = extra[header]
            local cls   = "ability-table-cell-" .. header:lower():gsub("%s+", "-")
            local content = value ~= nil and tostring(value) or "—"
            row = row .. ' || class="' .. cls .. '" | ' .. content
        end

        table.insert(out, "|-")
        table.insert(out, row)
    end

    table.insert(out, "|}")
    return table.concat(out, "\n")
end

-- TEST ONLY: plain-text dump of a list for review, one line per ability.
function p.dump(frame)
    local stat = mw.text.trim(frame.args[1] or ""):lower()
    local views, order = build_views()
    local spec = extra_columns[stat]
    local get_cells = spec and spec.get_cells or function() return {} end
    local out = {}
    for _, key in ipairs(order) do
        if p.is_member(stat, key) then
            local entry = views[key]
            local extra = get_cells(entry.view, entry.cardprops)
            local cell = extra["Spirit Scaling"] or "—"
            cell = mw.text.killMarkers(cell):gsub("<br>", " / "):gsub("<[^>]+>", "")
            table.insert(out, entry.heroName .. " | " .. entry.view["Name"] .. " | " .. cell .. " | " .. (extra["Notes"] or "—"))
        end
    end
    return "<pre>" .. mw.text.nowiki(table.concat(out, "\n")) .. "</pre>"
end

return p