Editing Module:Sandbox/Vergir

Jump to navigation Jump to search
Warning: You are not logged in. Once you make an edit, a temporary account will be created for you. Learn more. Log in or create an account to continue receiving notifications after this account expires, and to access other features.
The edit can be undone. Please check the comparison below to verify that this is what you want to do, and then publish the changes below to finish undoing the edit.
Latest revision Your text
Line 1: Line 1:
-- Renders sortable wikitables of abilities filtered by a named stat.
--------------------------------------------------------------------------------
 
-- Module:InfoboxHero
local Lists     = require("Module:AbilityTable/Lists")
--
local Notes     = require("Module:AbilityTable/Notes")
-- Renders the hero infobox. Called from [[Template:Infobox hero]], which passes
local Renderers = require("Module:AbilityTable/ComplexRenderers")
-- the editor's parameters through as the parent frame:
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')
-- TEST COPY of Module:AbilityTable with the proposed
local theme_data    = mw.loadJsonData('Data:Infobox_hero.json')
-- 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
local util        = require('Module:Utilities')
-- or one upgrade tier) as { key, value, multiply }, ordered the way the
local lang_module = require('Module:Lang')
-- ability card lists its props; props the card does not list come last, in
local dict_module = require('Module:Dictionary')
-- key order. `labels` is the view's cardprops map (see Module:AbilityTable).
local hero_module = require('Module:HeroData')
local function spirit_scalings(layer, labels)
local sb_module  = require('Module:StreetBrawlAbilities')
    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.
-- Shared hero-data lookups, see [[Module:HeroData]].
local function format_spirit(v)
local get_stat     = hero_module.get_stat
     return string.format("%.3g", v)
local get_scalings = hero_module.get_scalings
end
local has_stat    = hero_module.has_stat


-- Formats one Spirit scaling for the table cell through the compact {{Ss}}
local has_override_or_stat, stat_number
-- 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)
-- CONFIG
    if #names <= 1 then return names[1] or "" end
--------------------------------------------------------------------------------
    return table.concat(names, ", ", 1, #names - 1) .. " and " .. names[#names]
end


-- Renders Spirit Power scaling.
local DEFAULT_HERO_KEY = 'hero_targetdummy' -- used when |key= is missing/unknown
-- Lists every prop that scales with Spirit at base, one per line as an {{Ss}}
local DEFAULT_SIG_FIGS = 3
-- badge labelled with the prop's name from the ability card:
local FALLBACK_CARD    = 'Dummy card.png'
-- "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)
-- Row fields (all optional except a label):
    local tiers = {}   -- { tier = i, list = ... } in tier order
--   stat        Key in Data:HeroData.json. Resolved top-level, then .Weapon,
    local upgrades = ability["Upgrades"]
--               then .Weapon.AltFire. Omit for manual-input-only rows.
    if type(upgrades) == "table" then
--  param      |param= that overrides the value.
        -- ipairs rather than #: mw.loadJsonData tables have no length.
--  label      Key in Data:Lang_<code>.json.
        for i, tier in ipairs(upgrades) do
--   label_dict  Key in Data:Dictionary. Used instead of `label`.
            if type(tier) == "table" then
--  label_text  Literal, untranslated label. Used instead of `label`.
                table.insert(tiers, { tier = i, list = spirit_scalings(tier, labels) })
--  link        Target for the label, as 'Page' or 'Page#Anchor'. The language
            end
--              suffix is inserted for us, so write the plain English title.
        end
--  icon_link  Target for the icon. Defaults to `link`.
    end
--  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.


    -- Names used by more than one scaling prop of this ability get their Title
local SECTIONS = {
    -- appended so the rows can be told apart.
{
    local keys_by_name = {}
class  = 'infobox-h-dropdown-weapon',
     local function note_keys(list)
header = 'CitadelHeroStats_Weapon',
        for _, prop in ipairs(list) do
link  = 'Stats#Weapon',
            local info = labels[prop.key]
color  = 'Brown',
            if info then
rows = {
                keys_by_name[info.name] = keys_by_name[info.name] or {}
{
                keys_by_name[info.name][prop.key] = true
stat    = 'DPS',
            end
param  = 'dps',
        end
label  = 'DPS_label',
    end
link    = 'Damage per second',
    note_keys(base)
icon    = 'Damage.png',
    for _, t in ipairs(tiers) do note_keys(t.list) end
-- 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,
},
},
},
}


    local function label_of(key)
-- The "Information" block. Manual input only; hidden entirely when all are empty.
        local info = labels[key]
local INFO_ROWS = {
        if not info then return key end
{ param = 'pronouns',    label_dict = 'Pronouns' },
        local n = 0
{ param = 'voice_actor',  label_dict = 'VoiceActor', link = 'Voice actors' },
        for _ in pairs(keys_by_name[info.name] or {}) do n = n + 1 end
{ param = 'release_date', label_dict = 'ReleaseDate', link = 'Heroes by release' },
        if n > 1 and info.title then return info.name .. ": " .. info.title end
{ param = 'codenames',    label_dict = 'Codenames', tooltip = 'Codenames used for this hero in the game files' },
        return info.name
}
    end


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


    if #base > 0 then
--------------------------------------------------------------------------------
        local lines = {}
-- Data access
        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 = {}
has_override_or_stat = function(ctx, param, stat)
    for _, t in ipairs(tiers) do
if param and ctx.args[param] ~= '' then return true end
        local i, list = t.tier, t.list
return has_stat(ctx.hero, stat)
        local groups, group_order = {}, {}  -- Multiply upgrades, grouped by multiplier
end
        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
-- Rounded numeric value of a stat, honouring a |param= override.
    return cells
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
end


-- get_cells(ability, cardprops) -> { [column header] = cell text, Notes = ... }
local function format_value(raw, sig_figs)
-- `cardprops` maps the ability's prop keys to their display labels; most
if raw == nil then return '0' end
-- renderers ignore it.
local rounded = util.round_to_sig_fig(raw, sig_figs or DEFAULT_SIG_FIGS)
local extra_columns = {
return tostring(rounded ~= nil and rounded or raw)
    ["barrier"] = {
end
        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
-- Rendering helpers
-- ============================================================
--------------------------------------------------------------------------------


-- Merges auto-generated and human notes for full-table mode.
local function theme_var(hero_key, name)
-- Capitalizes human note, joins with ". ", ensures trailing period.
local hero_theme = theme_data[hero_key]
local function enrich_notes_full(extra, human_note)
if hero_theme and hero_theme[name] ~= nil then return hero_theme[name] end
    if human_note then
return theme_data['hero_default'][name]
        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


-- Merges auto-generated and human notes for compact mode.
local function label_of(row)
-- Decapitalizes human note (skips T{N} prefixes like "T1 slow"),
if row.label_text then return row.label_text end
-- joins with "; ", strips trailing period.
if row.label_dict then return dict_module.translate(row.label_dict) end
local function enrich_notes_compact(extra, human_note)
return lang_module.get_string(row.label)
    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


-- ============================================================
-- 'Damage Resistance#Spirit_Resist' -> 'Damage Resistance/ru#Spirit_Resist'
-- Ability views
local function page_link(ctx, target)
--
if not target then return '' end
-- In-game Ability may containe 1 or more records in AbilityData.json
local page, anchor = target:match('^([^#]*)(#?.*)$')
-- Example: Vexing Bolt is 2 records (Launch Bolt + Redirect Bolt).
return page .. ctx.if_lang .. anchor
 
end
-- "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
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
end


local _views = nil  -- card key -> { view = record, heroName = ..., abilityNum = ..., cardprops = ... }
-- Ammo and reload time are meaningless for heroes whose weapon never reloads.
local _order = nil  -- array of card keys, sorted by hero then slot
local NO_AMMO_STATS = { ClipSize = true, ReloadTime = true }
 
local function build_views()
    if _views then return _views, _order end
    _views, _order = {}, {}


    local hero_data = mw.loadJsonData("Data:HeroData.json")
-- Returns true when the row rendered.
    local active_heroes = {}
local function render_stat_row(ctx, tbl, row, color, is_first)
    for _, hero in pairs(hero_data) do
if row.show and not row.show(ctx) then return false end
        if type(hero) == "table"
if row.optional and not has_override_or_stat(ctx, row.param, row.stat) then return false end
            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 value, scaling_inner
    local cards        = mw.loadJsonData("Data:AbilityCards.json")
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 '')


    local card_of = {}
if row.stat and row.scaling ~= false then
    for _, hero_entry in pairs(cards) do
local parts = {}
        if type(hero_entry) == "table" then
for _, scaling in ipairs(get_scalings(ctx.hero, row.stat)) do
            local hero_name = hero_entry["Name"]
parts[#parts + 1] = hero_module.write_scalar_str(scaling.value, scaling.type)
            if hero_name and active_heroes[hero_name] then
end
                for slot_str, ability in pairs(hero_entry) do
scaling_inner = table.concat(parts)
                    local slot_num = tonumber(slot_str)
value = value .. '<span class="infobox-h-spiritboon">' .. scaling_inner .. '</span>'
                    local key = type(ability) == "table" and ability["Key"] or nil
else
                    if slot_num and key and ability_data[key] then
scaling_inner = ''
                        card_of[key] = { heroName = hero_name, abilityNum = slot_num, card = ability }
end
                    end
end
                end
            end
        end
    end


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


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


    return _views, _order
return true
end
end


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


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


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


-- Public: does ability `ability_key` belong to list `stat`?
local function render_information(ctx, parent)
function p.is_member(stat, ability_key, ability_num)
local present = {}
    local props = Lists.lists[stat]
for _, row in ipairs(INFO_ROWS) do
    if not props then return false end
if ctx.args[row.param] ~= '' then present[#present + 1] = row end
 
end
    local views = build_views()
if #present == 0 then return end
    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"]
parent:tag('tr'):tag('th')
    if name and excluded_set(stat)[name] then return false end
: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>')


    ability_num = ability_num or (entry and entry.abilityNum)
local tbl = parent:tag('tr'):tag('th')
    if not special_ok(stat, view, ability_num) then return false end
:attr('colspan', '2')
:addClass('infobox-h-subsection')
:tag('table'):addClass('infobox-h-subsection-child')


    return GameData.entity_matches(view, props)
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
end


-- ============================================================
local function render_title(ctx, parent)
-- Entry point
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


function p.render(frame)
local wrap = th:tag('div'):addClass('infobox-h-title-hero')
    local stat = mw.text.trim(frame.args[1] or ""):lower()
wrap:tag('div'):addClass('infobox-h-title-hero-left')


    local internal_keys = Lists.lists[stat]
-- Name logo: <Name>_name.svg, or <Name>_name_<lang>.svg on translated pages.
    if not internal_keys then
-- Heroes without a logo fall back to their name as plain text.
        return '<span class="error">AbilityTable: unknown list "' .. stat .. '".</span>'
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 hide_set = {}
local middle = wrap:tag('div'):addClass('infobox-h-title-hero-middle')
    local hide_param = mw.text.trim(frame.args["hide"] or "")
if util.page_exists('File:' .. svg .. '.svg') then
    if hide_param ~= "" then
middle:wikitext(ctx.frame:expandTemplate{ title = 'Icon/White', args = {
        for col in hide_param:gmatch("[^,]+") do hide_set[mw.text.trim(col)] = true end
string.format('[[File:%s.svg|%s|class=notpageimage]]',
    end
svg, theme_var(ctx.hero_key, 'logo-height')) } })
else
middle:wikitext(ctx.display_name)
end


    local compact = mw.text.trim(frame.args["compact"] or "") ~= ""
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 collapsed = mw.text.trim(frame.args["collapsed"] or ""):lower()
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 views, order = build_views()
local function render_tags(ctx, parent)
    local spec      = extra_columns[stat]
if not ctx.show_hero_tags then return end
    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 = {}
local tags = {}
    for _, key in ipairs(order) do
for i = 1, 3 do
        if p.is_member(stat, key) then
local tag = hero_module.hero_tag(ctx.hero_en, i)
            table.insert(filtered, key)
if tag ~= '' then
        end
tags[#tags + 1] = tag
    end
end
end
if #tags == 0 then return end


    if #filtered == 0 then
local inner = parent:tag('tr'):tag('td')
        return "No abilities found for list: " .. stat
: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 lists_url = mw.uri.fullUrl("Module:AbilityTable/Lists")
local function render_categories(ctx)
    local notes_url = mw.uri.fullUrl("Module:AbilityTable/Notes", { action = "edit" })
if mw.ustring.lower(ctx.args.nocat ~= '' and ctx.args.nocat or 'false') ~= 'false' then
    local disclaimer = frame:preprocess(
return ''
        '<div class="ability-table-disclaimer">'
end
        .. 'This is a dynamic list '
if get_stat(ctx.hero, 'IsDisabled') ~= false then return '' end
        .. '<span class="ability-table-list-name">"' .. stat .. '"</span>. '
        .. '[' .. tostring(lists_url) .. ' Edit contents]'
        .. ' · '
        .. '[' .. tostring(notes_url) .. ' Edit notes].'
        .. '</div>'
    )


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


        local items = {}
--------------------------------------------------------------------------------
        for _, hero in ipairs(hero_order) do
-- Entry point
            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)
function p.render(frame)
                enrich_notes_compact(extra, stat_notes and stat_notes[name])
local parent = frame:getParent()
local invoke_args = frame.args
local template_args = parent and parent.args or {}


                local note = extra["Notes"]
-- Missing parameters read as '' so callers never have to nil-check.
                if note then
local args = setmetatable({}, { __index = function(_, k)
                    table.insert(ability_parts, ability_cell .. " (" .. note .. ")")
local value = invoke_args[k]
                else
if value == nil then value = template_args[k] end
                    table.insert(ability_parts, ability_cell)
return value or ''
                end
end })
            end
            table.insert(items, "<li>" .. hero_cell .. ": " .. table.concat(ability_parts, ", ") .. "</li>")
        end


        return disclaimer .. "<ul>" .. table.concat(items) .. "</ul>"
-- |key= accepts a hero_* key or the hero's English name; unknown values fall
    end
-- 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 has_notes_header = false
local ctx = {
     local final_headers = {}
frame     = frame,
    for _, h in ipairs(headers) do
args      = args,
        if h == "Notes" then
hero      = hero,
            has_notes_header = true
hero_key  = hero_key,
        elseif not hide_set[h] then
hero_en  = hero.Name,
            table.insert(final_headers, h)
lang_code = lang_module.get_lang_code(),
        end
if_lang  = frame:expandTemplate{ title = 'If lang' },
    end
}
    local needs_notes = (has_notes_header or stat_notes ~= nil) and not hide_set["Notes"]
ctx.display_name = (args.name ~= '' and args.name) or lang_module.get_string(hero_key)
    if needs_notes then table.insert(final_headers, "Notes") end


    local table_class = 'wikitable sortable ability-table'
ctx.show_hero_tags = args.show_hero_tags ~= 'false'
    if collapsed ~= "" then
ctx.show_icon      = args.show_icon ~= 'false'
        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 = {}
-- Needed by both row visibility and the category rules.
    table.insert(out, disclaimer)
ctx.pellets = stat_number(ctx, 'BulletsPerShot', 'pellets_per_shot')
    table.insert(out, '{| class="' .. table_class .. '"')
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 header_row = '! class="ability-table-col-hero" | Hero'
local root = mw.html.create('div'):addClass('infobox-h ' .. hero_key)
                    .. ' !! class="ability-table-col-ability" | Ability'
root:tag('div'):addClass('infobox-h-background')
    for _, h in ipairs(final_headers) do
:wikitext(string.format('[[File:%s|%s|right|link=|class=notpageimage]]',
        header_row = header_row .. ' !! class="ability-table-col-'
theme_var(hero_key, 'bg-image'), theme_var(hero_key, 'bg-height')))
            .. h:lower():gsub("%s+", "-") .. '" | ' .. h
    end
    table.insert(out, header_row)


        local hero_counts = {}
local tbl = root:tag('table'):addClass('infobox-h-content')
    for _, key in ipairs(filtered) do
render_title(ctx, tbl)
        local hero = views[key].heroName
render_card(ctx, tbl)
        hero_counts[hero] = (hero_counts[hero] or 0) + 1
render_tags(ctx, tbl)
    end
for _, section in ipairs(SECTIONS) do
render_section(ctx, tbl, section)
end
render_information(ctx, tbl)


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


    for _, key in ipairs(filtered) do
--------------------------------------------------------------------------------
        local entry  = views[key]
-- TEMPORARY render-diff harness - NOT part of the live module.
        local ability = entry.view
-- {{#invoke:Sandbox/Vergir|diff}} or |diff|hero_atlas,hero_viscous
        local hero    = entry.heroName
--------------------------------------------------------------------------------


        local ability_cell = frame:expandTemplate{
-- Kept small: each render makes several expensive page_exists calls, and the
            title = "AbilityIcon",
-- parser allows only 100 per page.
            args = { ability["Name"] }
local SAMPLE_HEROES = {
        }
'hero_atlas',      -- burst-ish baseline, has scaling on many stats
 
'hero_viscous',    -- has an AltFire block
        local extra = get_cells(ability, entry.cardprops)
'hero_haze',      -- spreadshot
        enrich_notes_full(extra, stat_notes and stat_notes[ability["Name"]])
'hero_werewolf'-- Silver, has a transformed variant
 
'hero_targetdummy',-- the fallback hero
        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
function p.diff(frame)
            local value = extra[header]
local other = require('Module:InfoboxHero')
            local cls  = "ability-table-cell-" .. header:lower():gsub("%s+", "-")
local keys = {}
            local content = value ~= nil and tostring(value) or "—"
if frame.args[1] and frame.args[1] ~= '' then
            row = row .. ' || class="' .. cls .. '" | ' .. content
for key in string.gmatch(frame.args[1], '[^,%s]+') do
        end
table.insert(keys, key)
end
else
keys = SAMPLE_HEROES
end


        table.insert(out, "|-")
local checked, mismatches = 0, {}
        table.insert(out, row)
for _, hero_key in ipairs(keys) do
    end
local child = frame:newChild{ args = { key = hero_key } }
 
local ok_a, a = pcall(other.render, child)
    table.insert(out, "|}")
local ok_b, b = pcall(p.render, child)
    return table.concat(out, "\n")
if not ok_a then a = 'ERR:' .. tostring(a) end
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


-- TEST ONLY: plain-text dump of a list for review, one line per ability.
return 'Heroes rendered through both modules: ' .. checked ..
function p.dump(frame)
'\n\nMismatches: ' .. #mismatches ..
    local stat = mw.text.trim(frame.args[1] or ""):lower()
(#mismatches > 0 and ('\n* ' .. table.concat(mismatches, '\n* ')) or '\n\nIdentical output.')
    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
Please note that all contributions to The Deadlock Wiki are considered to be released under the Creative Commons Attribution-NonCommercial-ShareAlike (see Deadlock:Copyrights for details). If you do not want your writing to be edited mercilessly and redistributed at will, then do not submit it here.
You are also promising us that you wrote this yourself, or copied it from a public domain or similar free resource. Do not submit copyrighted work without permission!
Cancel Editing help (opens in new window)
Preview page with this template

Page included on this page: