Module:AbilityTable: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Vergir (talk | contribs)
m formatting
Vergir (talk | contribs)
Add compact parameter: one row per hero, abilities comma-separated, no extra columns (with help from vergir-bot LLM)
Line 376: Line 376:
     end
     end


     local key_lookup, name_to_key = build_lookup()
    local compact = mw.text.trim(frame.args["compact"] or "") ~= ""
 
     local key_lookup, _ = build_lookup()
     local spec      = extra_columns[stat]
     local spec      = extra_columns[stat]
     local headers    = spec and spec.headers or {}
     local headers    = spec and spec.headers or {}
Line 415: Line 417:
         return ia.abilityNum < ib.abilityNum
         return ia.abilityNum < ib.abilityNum
     end)
     end)
    -- Determine final headers
    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


     -- Disclaimer
     -- Disclaimer
Line 443: Line 432:
     table.insert(out, '{| class="wikitable sortable ability-table"')
     table.insert(out, '{| class="wikitable sortable ability-table"')


     local header_row = '! class="ability-table-col-hero" | Hero'
     if compact then
                    .. ' !! class="ability-table-col-ability" | Ability'
        -- Compact mode: one row per hero, abilities comma-separated in a single column.
    for _, h in ipairs(final_headers) do
        table.insert(out, '! class="ability-table-col-hero" | Hero'
        header_row = header_row .. ' !! class="ability-table-col-'
                      .. ' !! class="ability-table-col-abilities" | Abilities')
            .. h:lower():gsub("%s+", "-") .. '" | ' .. h
    end
    table.insert(out, header_row)


    for _, ability in ipairs(filtered) do
        -- Group abilities by hero, preserving sort order.
        local info = key_lookup[ability["Key"]]
        local hero_order  = {}  -- ordered list of hero names
        local hero_abilities = {}  -- heroName -> list of ability names
        for _, ability in ipairs(filtered) do
            local info = key_lookup[ability["Key"]]
            local hero = info and info.heroName or "—"
            if not hero_abilities[hero] then
                table.insert(hero_order, hero)
                hero_abilities[hero] = {}
            end
            table.insert(hero_abilities[hero], ability["Name"])
        end


         local hero_cell
         for _, hero in ipairs(hero_order) do
        if info then
             local hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { hero } }
             hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { info.heroName } }
            local ability_parts = {}
        else
            for _, name in ipairs(hero_abilities[hero]) do
             hero_cell = ""
                table.insert(ability_parts,
                    frame:expandTemplate{ title = "AbilityIcon", args = { name } })
            end
            table.insert(out, "|-")
             table.insert(out,
                '| class="ability-table-cell-hero" | '      .. hero_cell
            .. ' || class="ability-table-cell-abilities" | ' .. table.concat(ability_parts, ", "))
         end
         end
         local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { ability["Name"] } }
    else
        -- Normal mode: one row per ability with stat-specific columns.
         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 extra = get_cells(ability)
         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)


         if stat_notes and stat_notes[ability["Name"]] then
         for _, ability in ipairs(filtered) do
             local human = stat_notes[ability["Name"]]
             local info = key_lookup[ability["Key"]]
            extra["Notes"] = extra["Notes"] and (extra["Notes"] .. " " .. human) or human
        end


        local row = '| class="ability-table-cell-hero" | '    .. hero_cell
            local hero_cell
                .. ' || class="ability-table-cell-ability" | ' .. ability_cell
             if info then
        for _, header in ipairs(final_headers) do
                 hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { info.heroName } }
            local value = extra[header]
            local cls  = "ability-table-cell-" .. header:lower():gsub("%s+", "-")
            local content
             if value ~= nil then
                 local suffix = column_suffix[header] or ""
                content = (type(value) == "string" and value:sub(1,1) == "(")
                    and value or (value .. suffix)
             else
             else
                 content = "—"
                 hero_cell = "—"
             end
             end
             row = row .. ' || class="' .. cls .. '" | ' .. content
             local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { ability["Name"] } }
 
            local extra = get_cells(ability)
 
            if stat_notes and stat_notes[ability["Name"]] then
                local human = stat_notes[ability["Name"]]
                extra["Notes"] = extra["Notes"] and (extra["Notes"] .. " " .. human) or human
            end
 
            local row = '| class="ability-table-cell-hero" | '    .. hero_cell
                    .. ' || class="ability-table-cell-ability" | ' .. ability_cell
            for _, header in ipairs(final_headers) do
                local value = extra[header]
                local cls  = "ability-table-cell-" .. header:lower():gsub("%s+", "-")
                local content
                if value ~= nil then
                    local suffix = column_suffix[header] or ""
                    content = (type(value) == "string" and value:sub(1,1) == "(")
                        and value or (value .. suffix)
                else
                    content = "—"
                end
                row = row .. ' || class="' .. cls .. '" | ' .. content
            end
 
            table.insert(out, "|-")
            table.insert(out, row)
         end
         end
        table.insert(out, "|-")
        table.insert(out, row)
     end
     end



Revision as of 10:33, 15 May 2026

This module exposes two functions.

  • render generates a table of hero abilities matching a named list, and is invoked via {{AbilityTable}}.
  • is_member(list, ability_key, ability_num) is a shared function that decides whether a single ability belongs to a list (applying the same properties and exclusions); it is used both internally by render and externally by Module:AbilityHints, so the ability-hint icons always stay in sync with these tables.

See also


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

-- Maps lowercase friendly stat names to internal AbilityData property keys or values.
local stat_to_props = {
    ["charges"]    = { "AbilityCharges" },
    ["melee"]      = { "melee", "heavy_melee" },
    ["wpndmg"]     = { "melee", "heavy_melee", "weapon_damage_increase" },
    ["healreduce"] = { "HealAmpReceivePenaltyPercent", "HealAmpRegenPenaltyPercent", "DisableHealing" },
    ["debuffresist"]     = {
        -- Do not insert: AbilityDuration, TickRate, SlowDuration
        -- they do more harm than good and include a lot of excessive abilities to the list
        "StunDuration", "SleepDuration", "SilenceDuration", "SilenceDebuff",
        "PetrifyDuration", "HexDuration", "ImmobilizeDuration", "LiftDuration",
        "DebuffDuration", "HoldInPlaceDuration", "ExplodeDebuffDuration", "SlowPercent",
        "DebuffAccuracy", "TimeSlowDuration", "LateCheckoutStun", "BulletDamageAmpDuration",
        "BulletResistReduction", "BurnDuration", "EnemySlowPct", "MoveSlowPercent",
        "GroundDashReductionPercent", "StealDuration", "MaxSlowDuration", "EffectDuration",
        "BleedDuration", "VenomDuration",
        -- can also just list abilities if they don't have a reusable property
        "Hotel Guest", "Life Drain", "Combo", "Light Eater", "Spirit Lasso", "Card Trick"
    },
}

-- Editor-authored notes appended to the Notes column.
local human_notes = {
    ["charges"] = {
        ["Jar of Dead"]  = "Charges by being near when anything dies.",
    },
    ["melee"] = {
        ["Puddle Punch"] = "Can be parried.",
    },
    ["debuffresist"] = {
        ["Aura of Suffering"]  = "T1 slow, T3 extra damage. The effect is negligible.",
        ["Lightning Ball"]     = "T2 Slow. The effect is negligible.",
        ["Static Charge"]      = "Stun duration. Delay before stun is not affected.",
        ["Enchanter's Satchel"] = "T3 Slow.",
        ["Itani Lo Sahn"] = "Despite being described as \"debuff\", it can not be reduced with debuff resist.",
        ["Scorn"]              = "T3 extra damage from Mo & Krill.",
        ["Hotel Guest"]        = "Only T2 late check-out stun can be reduced.",
        ["Jar of Dead"]        = "T2 slow.",
        ["Grapple Arm"]        = "T1 extra damage from Bebop.",
        ["Binding Word"]       = "Immobilize Duration. Escape time is not affected.",
        ["Power Slash"]        = "T1 slow.",
        ["Kinetic Pulse"]      = "T2 bullet resist reduction and slow.",
        ["Pain Battery"]       = "T1 slow.",
        ["Power Surge"]        = "T3 Spirit Resist reduction.",
        ["Rain of Arrows"]     = "T2 slow.",
        ["Soul Exchange"]      = "T2 silence.",
        ["Ground Strike"]      = "T2 slow.",
        ["Flog"]               = "T2 Fire rate slow.",
    },
}

-- Listed by ability name. If adding please add a comment.
local exclude_list = {
    ["debuffresist"] = {
        "Bookwyrm", -- has debuffdurattion for some reason
        "Slam Fire", -- has debuffduration for some reason
        "Splatter", -- has SlowPercent but no duration to shave off.
    },
}

-- Unit strings appended to cell values by column name.
local column_suffix = {
    ["Time Between Charges"] = "s",
    ["Duration"]             = "s",
}

-- Actual code below

local p = {}
local GameData = require("Module:GameData")

-- ============================================================
-- Shared helpers
-- ============================================================

local function num_val(v)
    if type(v) == "number" then return v end
    if type(v) == "table"  then return v["Value"] end
    return nil
end

-- Searches Upgrades[] for a field and returns { tier = N, value = V } at its
-- first appearance, or nil if the field is absent from all upgrade tiers.
local function find_in_upgrades(ability, field)
    local upgrades = ability["Upgrades"]
    if type(upgrades) ~= "table" then return nil end
    for i, tier in ipairs(upgrades) do
        if type(tier) == "table" and tier[field] ~= nil then
            return { tier = i, value = num_val(tier[field]) }
        end
    end
    return nil
end

-- ============================================================
-- Per-stat cell renderers
-- Each function receives a guaranteed-matching ability record
-- and returns { ["Column Name"] = value }.
-- Human notes are appended by the framework, not here.
-- ============================================================

local function charges_cells(ability)
    local base_charges  = num_val(ability["AbilityCharges"])
    local base_cooldown = ability["AbilityCooldownBetweenCharge"]
    local has_base      = base_charges ~= nil and base_charges > 0
    local upgrade_info  = find_in_upgrades(ability, "AbilityCharges")

    local cooldown, upgrade_cooldown
    if has_base then
        if type(base_cooldown) == "number" and base_cooldown > 0 then
            cooldown = base_cooldown
        else
            local cd = find_in_upgrades(ability, "AbilityCooldownBetweenCharge")
            if cd and cd.value and cd.value > 0 then cooldown = cd.value end
        end
    else
        local cd = find_in_upgrades(ability, "AbilityCooldownBetweenCharge")
        if cd and cd.value and cd.value > 0 then upgrade_cooldown = cd.value end
    end

    local cells = {}

    if has_base then
        cells["Charges"] = tostring(base_charges)
        if cooldown then
            cells["Time Between Charges"] = tostring(cooldown)
        end
    end

    local note_parts = {}
    if upgrade_info then
        local n = upgrade_info.value or "?"
        local charge_str = n .. " charge" .. (n ~= 1 and "s" or "")
        if has_base then
            table.insert(note_parts, "+" .. charge_str .. " on T" .. upgrade_info.tier .. " upgrade.")
        else
            local cd_str = upgrade_cooldown and (upgrade_cooldown .. "s") or "unknown"
            table.insert(note_parts, "Becomes charged on T" .. upgrade_info.tier
                .. " upgrade with " .. charge_str
                .. " and " .. cd_str .. " time between charges.")
        end
    end
    if ability["AbilityChargesConditionally"] ~= nil then
        table.insert(note_parts, "Has a conditional charge.")
    end
    if #note_parts > 0 then
        cells["Notes"] = table.concat(note_parts, " ")
    end

    return cells
end

local function find_base_melee_scale(ability)
    for _, v in pairs(ability) do
        if type(v) == "table" then
            local scale = v["Scale"]
            if type(scale) == "table"
                and scale["Type"] == "melee"
                and type(scale["Value"]) == "number"
                and scale["Value"] > 0
            then
                return scale["Value"]
            end
        end
    end
    return nil
end

local function build_melee_upgrade_notes(ability, base_melee_scale)
    local upgrades = ability["Upgrades"]
    if type(upgrades) ~= "table" then return {} end
    local notes = {}
    for i, tier in ipairs(upgrades) do
        if type(tier) == "table" then
            for _, v in pairs(tier) do
                if type(v) == "table" then
                    local scale = v["Scale"]
                    if type(scale) == "table" and type(scale["Value"]) == "number" then
                        local stype = scale["Type"]
                        local delta = scale["Value"]
                        if stype == "heavy_melee" and delta > 0 then
                            table.insert(notes, "T" .. i .. " upgrade changes scaling to ×"
                                .. delta .. " of Heavy Melee damage.")
                        elseif stype == "melee" and delta ~= 0 and base_melee_scale then
                            local final = math.floor((base_melee_scale + delta) * 1000 + 0.5) / 1000
                            table.insert(notes, "T" .. i .. " upgrade adds ×"
                                .. delta .. " scaling (total ×" .. final .. ").")
                        end
                    end
                end
            end
        end
    end
    return notes
end

local function melee_cells(ability)
    local cells = {}
    local base_scale = find_base_melee_scale(ability)
    if base_scale then cells["Light Melee Scaling"] = "×" .. base_scale end
    local upgrade_notes = build_melee_upgrade_notes(ability, base_scale)
    if #upgrade_notes > 0 then cells["Notes"] = table.concat(upgrade_notes, " ") end
    return cells
end

local function find_base_duration(ability)
    for _, field in ipairs({ "VenomDuration", "DebuffDuration", "AbilityDuration", "BurnDuration" }) do
        local v = num_val(ability[field])
        if v and v > 0 then return v, field end
    end
    return nil, nil
end

local function healreduce_cells(ability)
    local cells = {}
    local notes = {}
    local base_dur, dur_field = find_base_duration(ability)

    local first_pct, first_tier, first_is_disable = nil, nil, false
    local base_receive = num_val(ability["HealAmpReceivePenaltyPercent"])
    local base_regen   = num_val(ability["HealAmpRegenPenaltyPercent"])
    local base_pct_raw = base_receive or base_regen
    if base_pct_raw and base_pct_raw ~= 0 then
        first_pct = math.abs(base_pct_raw)
    elseif ability["DisableHealing"] and ability["DisableHealing"] ~= 0 then
        first_is_disable = true
    else
        local upgrades = ability["Upgrades"]
        if type(upgrades) == "table" then
            for i, tier in ipairs(upgrades) do
                if type(tier) == "table" then
                    local up_raw = num_val(tier["HealAmpReceivePenaltyPercent"])
                                or num_val(tier["HealAmpRegenPenaltyPercent"])
                    if up_raw and up_raw ~= 0 then
                        first_pct, first_tier = math.abs(up_raw), i
                        break
                    elseif tier["DisableHealing"] and tier["DisableHealing"] ~= 0 then
                        first_is_disable, first_tier = true, i
                        break
                    end
                end
            end
        end
    end

    if first_is_disable then
        cells["Heal Reduction"] = "100%" .. (first_tier and " (T" .. first_tier .. ")" or "")
    elseif first_pct then
        cells["Heal Reduction"] = tostring(first_pct) .. "%" .. (first_tier and " (T" .. first_tier .. ")" or "")
    end
    if base_dur then cells["Duration"] = tostring(base_dur) end

    local upgrades = ability["Upgrades"]
    if type(upgrades) == "table" then
        local running_pct, running_dur = first_pct, base_dur
        local first_pct_seen = (first_tier == nil)
        for i, tier in ipairs(upgrades) do
            if type(tier) == "table" then
                local tier_notes = {}
                local pct_delta = num_val(tier["HealAmpReceivePenaltyPercent"])
                               or num_val(tier["HealAmpRegenPenaltyPercent"])
                if first_tier == i then
                    first_pct_seen = true
                    if first_pct then running_pct = first_pct end
                elseif first_pct_seen and pct_delta and pct_delta ~= 0 then
                    local delta_abs = math.abs(pct_delta)
                    local total = (running_pct or 0) + delta_abs
                    table.insert(tier_notes, "T" .. i .. " upgrade increases heal reduction by "
                        .. delta_abs .. "% (total " .. total .. "%).")
                    running_pct = total
                end
                if dur_field then
                    local dur_delta = num_val(tier[dur_field])
                    if dur_delta and dur_delta ~= 0 and running_dur then
                        local total_dur = running_dur + dur_delta
                        table.insert(tier_notes, "T" .. i .. " upgrade "
                            .. (dur_delta > 0 and "increases" or "decreases")
                            .. " duration by " .. math.abs(dur_delta)
                            .. "s (total " .. total_dur .. "s).")
                        running_dur = total_dur
                    end
                end
                for _, n in ipairs(tier_notes) do table.insert(notes, n) end
            end
        end
    end

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

local extra_columns = {
    ["charges"] = {
        headers   = { "Charges", "Time Between Charges", "Notes" },
        get_cells = charges_cells,
    },
    ["melee"] = {
        headers   = { "Light Melee Scaling", "Notes" },
        get_cells = melee_cells,
    },
    ["healreduce"] = {
        headers   = { "Heal Reduction", "Duration", "Notes" },
        get_cells = healreduce_cells,
    },
}

-- ============================================================
-- Hero lookup
--   _key_lookup  : ability key  -> { heroName, abilityNum }
--   _name_to_key : ability name -> ability key
-- Both built together from HeroData + AbilityCards.
-- ============================================================

local _key_lookup  = nil
local _name_to_key = nil

local function build_lookup()
    if _key_lookup then return _key_lookup, _name_to_key end
    _key_lookup  = {}
    _name_to_key = {}

    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 cards = mw.loadJsonData("Data:AbilityCards.json")
    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)
                    if slot_num and type(ability) == "table" then
                        local info = { heroName = hero_name, abilityNum = slot_num }
                        if ability["Key"] then
                            _key_lookup[ability["Key"]] = info
                        end
                        if ability["Name"] and ability["Key"] then
                            _name_to_key[ability["Name"]] = ability["Key"]
                        end
                    end
                end
            end
        end
    end

    return _key_lookup, _name_to_key
end

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

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

    local internal_keys = stat_to_props[stat]
    if not internal_keys then
        return '<span class="error">AbilityTable: unknown stat "' .. 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 key_lookup, _ = build_lookup()
    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 = human_notes[stat]

    -- Build excluded-name set for this stat
    local excluded = {}
    if exclude_list[stat] then
        for _, name in ipairs(exclude_list[stat]) do
            excluded[name] = true
        end
    end

    local abilities = GameData.get_entities(GameData.Dataset.ABILITIES, internal_keys)

    -- Keep only abilities belonging to active, released heroes and not excluded
    local filtered = {}
    for _, ability in ipairs(abilities) do
        local key = ability["Key"]
        if key and key_lookup[key] and not excluded[ability["Name"]] then
            table.insert(filtered, ability)
        end
    end

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

    -- Sort by hero name, then ability slot number.
    table.sort(filtered, function(a, b)
        local ia = key_lookup[a["Key"]]
        local ib = key_lookup[b["Key"]]
        if not ia and not ib then return (a["Name"] or "") < (b["Name"] or "") end
        if not ia then return false end
        if not ib then return true end
        if ia.heroName ~= ib.heroName then return ia.heroName < ib.heroName end
        return ia.abilityNum < ib.abilityNum
    end)

    -- Disclaimer
    local edit_url = mw.uri.fullUrl("Module:AbilityTable", { action = "edit" })
    local disclaimer = frame:preprocess(
        '<div class="ability-table-disclaimer">'
        .. "This is an automatic list. "
        .. "[" .. tostring(edit_url) .. " Click here]"
        .. " to add notes or correct the contents."
        .. "</div>"
    )

    local out = {}
    table.insert(out, disclaimer)
    table.insert(out, '{| class="wikitable sortable ability-table"')

    if compact then
        -- Compact mode: one row per hero, abilities comma-separated in a single column.
        table.insert(out, '! class="ability-table-col-hero" | Hero'
                       .. ' !! class="ability-table-col-abilities" | Abilities')

        -- Group abilities by hero, preserving sort order.
        local hero_order  = {}  -- ordered list of hero names
        local hero_abilities = {}  -- heroName -> list of ability names
        for _, ability in ipairs(filtered) do
            local info = key_lookup[ability["Key"]]
            local hero = info and info.heroName or "—"
            if not hero_abilities[hero] then
                table.insert(hero_order, hero)
                hero_abilities[hero] = {}
            end
            table.insert(hero_abilities[hero], ability["Name"])
        end

        for _, hero in ipairs(hero_order) do
            local hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { hero } }
            local ability_parts = {}
            for _, name in ipairs(hero_abilities[hero]) do
                table.insert(ability_parts,
                    frame:expandTemplate{ title = "AbilityIcon", args = { name } })
            end
            table.insert(out, "|-")
            table.insert(out,
                '| class="ability-table-cell-hero" | '      .. hero_cell
             .. ' || class="ability-table-cell-abilities" | ' .. table.concat(ability_parts, ", "))
        end
    else
        -- Normal mode: one row per ability with stat-specific columns.
        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 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)

        for _, ability in ipairs(filtered) do
            local info = key_lookup[ability["Key"]]

            local hero_cell
            if info then
                hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { info.heroName } }
            else
                hero_cell = "—"
            end
            local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { ability["Name"] } }

            local extra = get_cells(ability)

            if stat_notes and stat_notes[ability["Name"]] then
                local human = stat_notes[ability["Name"]]
                extra["Notes"] = extra["Notes"] and (extra["Notes"] .. " " .. human) or human
            end

            local row = '| class="ability-table-cell-hero" | '     .. hero_cell
                     .. ' || class="ability-table-cell-ability" | ' .. ability_cell
            for _, header in ipairs(final_headers) do
                local value = extra[header]
                local cls   = "ability-table-cell-" .. header:lower():gsub("%s+", "-")
                local content
                if value ~= nil then
                    local suffix = column_suffix[header] or ""
                    content = (type(value) == "string" and value:sub(1,1) == "(")
                        and value or (value .. suffix)
                else
                    content = "—"
                end
                row = row .. ' || class="' .. cls .. '" | ' .. content
            end

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

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

return p