Module:AbilityTable

From The Deadlock Wiki
Revision as of 21:57, 22 May 2026 by ~2026-RescueLeapReach229Temporary account has expired (talk) (remove melee from wpndmg list)
Jump to navigation Jump to search

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 friendly stat names to internal property keys or values.
local stat_to_props = {
    ["charges"]    = { "AbilityCharges" },
    ["melee"]      = { "melee", "heavy_melee" },
    ["wpndmg"]     = { "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"
    },
    ["moveslow"] = {
    	"SlowPercent", "EnemySlowPct", "MoveSlowPercent", "MaxSlowPercent", "MoveSpeedSlowPct", "MovementSpeedSlow",
    	"MovementSlowPct", "ClubSlowPercent", "MoveSpeedPenaltyPerStack", "MovementSlow"
    },
    ["dashslow"]  = { "GroundDashReductionPercent", "MaxGroundDashReductionPercent", "MoveSpeedAndDashSlowPct", "EnemyDashSlowPercent" },
    ["reactivebarrier"] = {
        -- Do not insert: DebuffDuration, SlowPercent and similar soft CC — they don't trigger Reactive Barrier
        "StunDuration", "SleepDuration", "SilenceDuration", "SilenceDebuff", "PetrifyDuration", "HexDuration",
        "ImmobilizeDuration", "LiftDuration", "HoldInPlaceDuration", "RestrictionDuration", "PullDuration", "PullDownDuration",
        -- engine-side CCs with no matching data prop
        "Itani Lo Sahn", "Dazzling Trick", "Ground Strike", "Hotel Guest", "Dazzling Trick"
    },
    ["heal"] = {
        "HealingPerSecond", "HealAmount", "HealFixedHealth", "FlatHealthHealing", "HealingFactor", "MaxHealthRegen",
        "ExternalBonusHealthRegen", "BonusHealthRegen", "LifeStealPercentOnHit", "LifestealPercentHero", "MissingHealthPercentHeal", "MissingHealthBurstPct",
        "MissingHPHeal", "TechLifestealPercent", "AbilityLifestealPercentHero", "HealthStealPctHero", "BulletLifestealPercent", "TotalHealthRegen",
        "DamageHealMult", "HealPctVsHeroes", "InfestHeal", "HealOnKillPct", "HealMaxHealth", "HealMaxHealthPercent",
        "RegenIncomingDamagePercent",
        -- raw abilities
        "Rake", "Card Trick",
    },
}

-- Editor-authored notes go here.
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.",
        ["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.",
    },
    ["moveslow"] = {
        ["Grapple Arm"]   = "Applies a short strong slow when the victim reaches Bebop.",
        ["Heavy Barrage"]  = "Slows McGinnis by unknown amount.",
    },
    ["reactivebarrier"] = {
        ["Rend"]              = "Triggers on T3 upgrade only.",
        ["Soul Exchange"]     = "Triggers on T2 upgrade only.",
        ["Air Drop"]          = "Triggers on T3 upgrade only.",
        ["Entangling Thorns"] = "Triggers on T3 upgrade only.",
        ["Ground Strike"]     = "Triggers only when slamming a mid-air enemy into the ground.",
        ["Djinn's Mark"]      = "Triggers on T3 upgrade only.",
        ["Time Wall"]         = "Triggers on T2 upgrade only.",
    },
    ["heal"] = {
    	["Siphon Life"]           = "Lifesteals off damage dealt while active.",
        ["Infernal Resilience"]   = "Passively restores health for a percentage of all incoming damage.",
        ["Flawless Advance"]      = "Heals a flat amount per hit. Unlocked by T1 upgrade.",
        ["Exploding Uppercut"]    = "Restores a portion of missing health on use. Unlocked by T3 upgrade.",
        ["Leaping Slash"]         = "Heals a flat amount on hero hit. Unlocked by T1 upgrade.",
        ["Return to Shadows"]     = "Heals a flat amount on use. Unlocked by T3 upgrade.",
        ["Light Eater"]           = "Grants a buff that gives spirit lifesteal while active.",
        ["Bloodscent"]            = "Restores a portion of missing health on kill.",
        ["Rain of Arrows"]        = "Grants bullet and spirit lifesteal while active. Unlocked by T3 upgrade.",
        ["Napalm"]                = "Lifesteals off all damage dealt to napalmed targets. Unlocked by T2 upgrade.",
        ["Concussive Combustion"] = "Lifesteals off damage dealt. Unlocked by T2 upgrade.",
        ["Kudzu Connection"]      = "Grants bullet lifesteal. Also replicates a portion of healing.",
        ["Rejuvenating Aurora"]   = "Heals per second while channeling. T3 upgrade also adds passive max health regen.",
        ["Stone Form"]            = "Heals a percentage of max HP on cast.",
        ["Frost Grenade"]         = "Heals Kelvin and nearby allies for a flat amount.",
        ["Frozen Shelter"]        = "Regenerates health for all allies inside the dome.",
        ["Flog"]                  = "Heals Lash for a percentage of damage dealt to heroes.",
        ["Medicinal Specter"]     = "Heals nearby allies per second.",
        ["Rake"]                  = "Heals a flat amount on kill.",
        ["Scorn"]                 = "Heals for a percentage of damage dealt.",
        ["Combo"]                 = "Lifesteals off damage dealt. Unlocked by T1 upgrade.",
        ["Rallying Charge"]       = "Heal affected allies and Paige for a flat amount.",
        ["Tag Along"]             = "Restores a portion of target's missing health on attachment, then heals per second for a short duration.",
        ["Lil Helpers"]           = "Regenerates health for any trooper the helpers are attached to.",
        ["Lycan Curse"]           = "Restores a percentage of missing health on activation.",
        ["Go For The Throat"]     = "Lifesteals off melee damage dealt.",
        ["Pain Battery"]          = "Restores a portion of missing health on hero hit. Unlocked by T3 upgrade.",
        ["Jumpstart"]             = "Grants a flat health regen buff on use.",
        ["The Cube"]              = "Regenerates health for the target.",
        ["Last Stand"]            = "Lifesteals off all damage dealt while active.",
        ["Card Trick"]            = "Heals Wraith for a flat amount when a Heart or Joker card hits.",
        ["Crimson Slash"]         = "Heals a flat amount on hero hit.",
        ["Shadow Transformation"] = "Restores a percentage of max health per hero kill.",
    },
}

-- Type here to exclude ability from a list. If adding please include a comment.
local exclude_list = {
    ["debuffresist"] = {
        "Bookwyrm", -- has debuffduration for some reason
        "Itani Lo Sahn", -- has debuffduration but does not get reduced
        "Slam Fire", -- has debuffduration for some reason
        "Splatter", -- has SlowPercent but no duration to shave off.
    },
    ["moveslow"] = {
        "Riposte", -- has a 40% 4s slow somehow? (SlowPercent + SlowDuration)
        "Grasping Hands", -- has SlowPercent
    },
    ["dashslow"] = {
        "Itani Lo Sahn", -- has GroundDashReductionPercent but also disables stamina.
    },
    ["heal"] = {
    	"Riposte" -- has AbilityLifestealPercentHero, but doesn't deal damage to heal off of.
    }
}

-- Unit strings appended to cell values by column name.
local column_suffix = {
    ["Time Between Charges"] = "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

-- Finds the first non-zero value for any of the given fields, checking base
-- then upgrades in order. Returns field, tier (nil=base, N=upgrade), abs value.
local function find_first_value(ability, fields)
    for _, field in ipairs(fields) do
        local v = num_val(ability[field])
        if v and v ~= 0 then return field, nil, math.abs(v) end
    end
    local upgrades = ability["Upgrades"]
    if type(upgrades) == "table" then
        for i, tier in ipairs(upgrades) do
            if type(tier) == "table" then
                for _, field in ipairs(fields) do
                    local v = num_val(tier[field])
                    if v and v ~= 0 then return field, i, math.abs(v) end
                end
            end
        end
    end
    return nil, nil, 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 healreduce_cells(ability)
    local cells = {}
    local notes = {}
    local props = stat_to_props["healreduce"]

    local first_pct, first_tier, first_is_disable = nil, nil, false

    for _, prop in ipairs(props) do
        local v = num_val(ability[prop])
        if v and v ~= 0 then
            if prop == "DisableHealing" then
                first_is_disable = true
            else
                first_pct = math.abs(v)
            end
            break
        end
    end
    if not first_pct and not first_is_disable then
        local upgrades = ability["Upgrades"]
        if type(upgrades) == "table" then
            for i, tier in ipairs(upgrades) do
                if type(tier) == "table" then
                    for _, prop in ipairs(props) do
                        local v = num_val(tier[prop])
                        if v and v ~= 0 then
                            if prop == "DisableHealing" then
                                first_is_disable, first_tier = true, i
                            else
                                first_pct, first_tier = math.abs(v), i
                            end
                            break
                        end
                    end
                end
                if first_pct or first_is_disable then break 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

    local upgrades = ability["Upgrades"]
    if type(upgrades) == "table" then
        local running        = first_pct
        local first_pct_seen = (first_tier == nil)

        for i, tier in ipairs(upgrades) do
            if type(tier) == "table" then
                if first_tier == i then
                    first_pct_seen = true
                    if first_pct then running = first_pct end
                elseif first_pct_seen then
                    local delta = nil
                    for _, prop in ipairs(props) do
                        if prop ~= "DisableHealing" then
                            local v = num_val(tier[prop])
                            if v and v ~= 0 then delta = math.abs(v); break end
                        end
                    end
                    if delta then
                        local total = (running or 0) + delta
                        table.insert(notes, "T" .. i .. " upgrade increases heal reduction by "
                            .. delta .. "% (total " .. total .. "%).")
                        running = total
                    end
                end
            end
        end
    end

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

-- Generic renderer for a numeric stat column.
-- col_name: the column header string
-- fields:   ordered list of property names (first non-zero wins)
-- suffix:   string appended to values in cell and notes (e.g. "%" or "")
-- Upgrade deltas are additive; notes track running totals.
local function val_stat_cells(ability, col_name, fields, suffix)
    suffix = suffix or ""
    local cells = {}
    local notes = {}

    local first_field, first_tier, first_val = find_first_value(ability, fields)
    if not first_field then return cells end

    local cell_str = tostring(first_val) .. suffix
    if first_tier then cell_str = cell_str .. " (T" .. first_tier .. ")" end
    cells[col_name] = cell_str

    local upgrades = ability["Upgrades"]
    if type(upgrades) == "table" then
        local running    = first_val
        local seen_first = (first_tier == nil)

        for i, tier in ipairs(upgrades) do
            if type(tier) == "table" then
                if first_tier == i then
                    seen_first = true
                elseif seen_first then
                    local delta = nil
                    for _, field in ipairs(fields) do
                        local v = num_val(tier[field])
                        if v and v ~= 0 then delta = math.abs(v); break end
                    end
                    if delta then
                        local total = running + delta
                        table.insert(notes, "T" .. i .. " upgrade increases "
                            .. col_name:lower() .. " by " .. delta .. suffix
                            .. " (total " .. total .. suffix .. ").")
                        running = total
                    end
                end
            end
        end
    end

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

local function moveslow_cells(ability)
    local cells = val_stat_cells(ability, "Move Slow", stat_to_props["moveslow"], "%")

    local channel_slow = num_val(ability["ChannelSlowPercent"])
    if channel_slow and channel_slow ~= 0 then
        local note = "Also slows the caster for " .. math.abs(channel_slow) .. "%."
        cells["Notes"] = cells["Notes"] and (cells["Notes"] .. " " .. note) or note
    end

    return cells
end

local function dashslow_cells(ability)
    local props = stat_to_props["dashslow"]

    local caster_dash_slow = nil
    local base_ground = num_val(ability["GroundDashReductionPercent"])
    if base_ground and base_ground ~= 0 then
        local has_enemy_prop = false
        for _, p in ipairs(stat_to_props["dashslow"]) do
            if p ~= "GroundDashReductionPercent" then
                local v = num_val(ability[p])
                if v and v ~= 0 then has_enemy_prop = true; break end
            end
        end
        if not has_enemy_prop then
            local upgrades = ability["Upgrades"]
            if type(upgrades) == "table" then
                for _, tier in ipairs(upgrades) do
                    if type(tier) == "table" then
                        for _, p in ipairs(stat_to_props["dashslow"]) do
                            if p ~= "GroundDashReductionPercent" then
                                local v = num_val(tier[p])
                                if v and v ~= 0 then has_enemy_prop = true; break end
                            end
                        end
                    end
                    if has_enemy_prop then break end
                end
            end
        end
        if has_enemy_prop then
            caster_dash_slow = math.abs(base_ground)
            local filtered = {}
            for _, p in ipairs(props) do
                if p ~= "GroundDashReductionPercent" then
                    table.insert(filtered, p)
                end
            end
            props = filtered
        end
    end

    local cells = val_stat_cells(ability, "Dash Slow", props, "%")

    if caster_dash_slow then
        local note = "Gives " .. caster_dash_slow .. "% dash slow for the caster."
        cells["Notes"] = cells["Notes"] and (cells["Notes"] .. " " .. note) or note
    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", "Notes" },
        get_cells = healreduce_cells,
    },
    ["moveslow"] = {
        headers   = { "Move Slow", "Notes" },
        get_cells = moveslow_cells,
    },
    ["dashslow"] = {
        headers   = { "Dash Slow", "Notes" },
        get_cells = dashslow_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]

    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)

    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

    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)

    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>"
    )

    if compact then
        local hero_order     = {}
        local hero_abilities = {}
        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 items = {}
        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
                local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { name } }
                local note = stat_notes and stat_notes[name]
                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 out = {}
    table.insert(out, disclaimer)
    table.insert(out, '{| class="wikitable sortable ability-table"')

    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

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

return p