Module:AbilityTable: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Add melee extra columns with Light Melee Scaling and automated heavy melee upgrade notes (with help from vergir-bot LLM)
grouped repeated hero cells with rowspan
 
(90 intermediate revisions by 9 users not shown)
Line 1: Line 1:
-- Renders sortable wikitables of abilities filtered by a named stat.
-- Renders sortable wikitables of abilities filtered by a named stat.


-- Maps lowercase friendly stat names to internal AbilityData property keys or values.
local Lists    = require("Module:AbilityTable/Lists")
-- Entries are matched either as key names (property exists with non-zero value)
local Notes    = require("Module:AbilityTable/Notes")
-- or as plain string values anywhere in the record (e.g. Scale.Type = "melee").
local Renderers = require("Module:AbilityTable/ComplexRenderers")
local stat_to_props = {
local GameData  = require("Module:GameData")
    ["charges"] = { "AbilityCharges" },
 
    ["melee"]  = { "melee", "heavy_melee", "light_melee" },
local p = {}
}


-- Editor-authored notes appended to the Notes column.
local extra_columns = {
local human_notes = {
    ["barrier"] = {
        headers  = { "Barrier" },
        get_cells = Renderers.RenderBarrier,
    },
     ["charges"] = {
     ["charges"] = {
         ["Jar of Dead"]  = "Charges by being near when anything dies.",
         headers  = { "Charges", "Time Between Charges", "Notes" },
        get_cells = Renderers.RenderCharges,
     },
     },
     ["melee"] = {
     ["melee"] = {
         ["Puddle Punch"] = "Can be parried.",
         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 = Renderers.RenderSpiritScaling,
    },
    ["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,
     },
     },
}
}
-- 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
-- Note enrichment
-- ============================================================
-- ============================================================


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


-- Searches Upgrades[] for a field and returns { tier = N, value = V } at its
-- Merges auto-generated and human notes for compact mode.
-- first appearance, or nil if the field is absent from all upgrade tiers.
-- Decapitalizes human note (skips T{N} prefixes like "T1 slow"),
local function find_in_upgrades(ability, field)
-- joins with "; ", strips trailing period.
     local upgrades = ability["Upgrades"]
local function enrich_notes_compact(extra, human_note)
    if type(upgrades) ~= "table" then return nil end
     if human_note then
    for i, tier in ipairs(upgrades) do
        local first = human_note:sub(1,1)
        if type(tier) == "table" and tier[field] ~= nil then
        if first:match("%u") and not human_note:sub(2,2):match("%d") then
             return { tier = i, value = num_val(tier[field]) }
             human_note = first:lower() .. human_note:sub(2)
         end
         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
    return nil
end
end


-- ============================================================
-- ============================================================
-- Per-stat cell renderers
-- Ability views
-- Each function receives a guaranteed-matching ability record
--
-- and returns { ["Column Name"] = value }.
-- In-game Ability may containe 1 or more records in AbilityData.json
-- Human notes are appended by the framework, not here.
-- Example: Vexing Bolt is 2 records (Launch Bolt + Redirect Bolt).
 
-- "Ability View" is a main-record + any relevant sub-ability records.
-- ============================================================
-- ============================================================


local function charges_cells(ability)
local _views = nil  -- card key -> { view = record, heroName = ..., abilityNum = ... }
    local base_charges  = num_val(ability["AbilityCharges"])
local _order = nil  -- array of card keys, sorted by hero then slot
    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
local function build_views()
     if has_base then
     if _views then return _views, _order end
        if type(base_cooldown) == "number" and base_cooldown > 0 then
     _views, _order = {}, {}
            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 = {}
     local hero_data = mw.loadJsonData("Data:HeroData.json")
 
    local active_heroes = {}
    if has_base then
    for _, hero in pairs(hero_data) do
        cells["Charges"] = tostring(base_charges)
        if type(hero) == "table"
         if cooldown then
            and hero["IsDisabled"] == false
             cells["Time Between Charges"] = tostring(cooldown)
            and type(hero["Name"]) == "string"
            and (hero["IsSelectable"] == nil or hero["IsSelectable"] ~= false)
         then
             active_heroes[hero["Name"]] = true
         end
         end
     end
     end


     local note_parts = {}
     local ability_data = mw.loadJsonData(GameData.Dataset.ABILITIES)
    if upgrade_info then
    local cards        = mw.loadJsonData("Data:AbilityCards.json")
        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
     local card_of = {}
end
     for _, hero_entry in pairs(cards) do
 
         if type(hero_entry) == "table" then
-- Finds the base light melee scale value for a melee ability.
             local hero_name = hero_entry["Name"]
-- Searches all top-level properties for the first one whose Scale.Type
             if hero_name and active_heroes[hero_name] then
-- is "melee" and whose scale value is > 0.
                for slot_str, ability in pairs(hero_entry) do
local function find_base_melee_scale(ability)
                    local slot_num = tonumber(slot_str)
     for _, v in pairs(ability) do
                    local key = type(ability) == "table" and ability["Key"] or nil
         if type(v) == "table" then
                    if slot_num and key and ability_data[key] then
             local scale = v["Scale"]
                        card_of[key] = { heroName = hero_name, abilityNum = slot_num }
             if type(scale) == "table"
                    end
                and scale["Type"] == "melee"
                 end
                and type(scale["Value"]) == "number"
                and scale["Value"] > 0
            then
                 return scale["Value"]
             end
             end
         end
         end
     end
     end
    return nil
end


-- Finds the first upgrade tier that introduces a heavy_melee scale > 0.
    -- Attach each non-card record to the longest card key it prefixes.
-- Returns { tier = N, scale = V } or nil.
     local subs = {}
local function find_heavy_melee_upgrade(ability)
     for key, record in pairs(ability_data) do
     local upgrades = ability["Upgrades"]
         if not card_of[key] and record["IsDisabled"] ~= true then
    if type(upgrades) ~= "table" then return nil end
            local parent = nil
     for i, tier in ipairs(upgrades) do
             for card_key in pairs(card_of) do
         if type(tier) == "table" then
                 if key:sub(1, #card_key + 1) == card_key .. "_"
             for _, v in pairs(tier) do
                     and (parent == nil or #card_key > #parent)
                 if type(v) == "table" then
                then
                     local scale = v["Scale"]
                    parent = card_key
                    if type(scale) == "table"
                        and scale["Type"] == "heavy_melee"
                        and type(scale["Value"]) == "number"
                        and scale["Value"] > 0
                    then
                        return { tier = i, scale = scale["Value"] }
                    end
                 end
                 end
            end
            if parent then
                subs[parent] = subs[parent] or {}
                table.insert(subs[parent], record)
             end
             end
         end
         end
     end
     end
    return nil
end
local function melee_cells(ability)
    local cells = {}


     local base_scale = find_base_melee_scale(ability)
     for key, info in pairs(card_of) do
    if base_scale then
        local view = {}
         cells["Light Melee Scaling"] = "×" .. base_scale
        for k, v in pairs(ability_data[key]) do view[k] = v end
         view["_subabilities"] = subs[key]
        _views[key] = { view = view, heroName = info.heroName, abilityNum = info.abilityNum }
        table.insert(_order, key)
     end
     end


     local heavy_upgrade = find_heavy_melee_upgrade(ability)
     table.sort(_order, function(a, b)
    if heavy_upgrade then
         local ia, ib = _views[a], _views[b]
         cells["Notes"] = "Gains ×" .. heavy_upgrade.scale
        if ia.heroName ~= ib.heroName then return ia.heroName < ib.heroName end
            .. " [[Heavy Melee]] scaling on T" .. heavy_upgrade.tier .. " upgrade."
        return ia.abilityNum < ib.abilityNum
     end
     end)


     return cells
     return _views, _order
end
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,
    },
}


-- ============================================================
-- ============================================================
-- Hero lookup (ability key -> { heroName, abilityNum })
-- Membership
-- Pieces of code that help in telling  "is ability X a member of list Y".
-- main function is: p.is_member()
-- ============================================================
-- ============================================================


local _hero_lookup = nil
-- Memoized name-exclusion set per list (from Lists.exclude_abilities).
local _excluded = {}
local function excluded_set(stat)
    if _excluded[stat] then return _excluded[stat] end
    local set = {}
    for _, name in ipairs(Lists.exclude_abilities[stat] or {}) do
        set[name] = true
    end
    _excluded[stat] = set
    return set
end
 
-- Per-list rules that can't be expressed as prop lists or exclusions.
-- Returns true to keep the ability, false to drop it.
local function special_ok(stat, record, ability_num)
    if stat == "dispelmagic" and ability_num == 4 then return false end
    return true
end
 
-- Public: does ability `ability_key` belong to list `stat`?
function p.is_member(stat, ability_key, ability_num)
    local props = Lists.lists[stat]
    if not props then return false end


local function build_hero_lookup()
    local views = build_views()
     if _hero_lookup then return _hero_lookup end
     local entry = views[ability_key]
     _hero_lookup = {}
     local view  = entry and entry.view


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


     local cards = mw.loadJsonData("Data:AbilityCards.json")
     local name = view["Name"]
    for _, hero_entry in pairs(cards) do
    if name and excluded_set(stat)[name] then return false end
        if type(hero_entry) == "table" then
 
            local hero_name = hero_entry["Name"]
    ability_num = ability_num or (entry and entry.abilityNum)
            if hero_name and active_heroes[hero_name] then
    if not special_ok(stat, view, ability_num) then return false end
                for slot_str, ability in pairs(hero_entry) do
                    local slot_num = tonumber(slot_str)
                    if slot_num and type(ability) == "table" and ability["Key"] then
                        _hero_lookup[ability["Key"]] = {
                            heroName  = hero_name,
                            abilityNum = slot_num,
                        }
                    end
                end
            end
        end
    end


     return _hero_lookup
     return GameData.entity_matches(view, props)
end
end


Line 231: Line 215:
     local stat = mw.text.trim(frame.args[1] or ""):lower()
     local stat = mw.text.trim(frame.args[1] or ""):lower()


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


    -- Build set of columns to hide from the "hide" parameter
     local hide_set = {}
     local hide_set = {}
     local hide_param = mw.text.trim(frame.args["hide"] or "")
     local hide_param = mw.text.trim(frame.args["hide"] or "")
     if hide_param ~= "" then
     if hide_param ~= "" then
         for col in hide_param:gmatch("[^,]+") do
         for col in hide_param:gmatch("[^,]+") do hide_set[mw.text.trim(col)] = true end
            hide_set[mw.text.trim(col)] = true
        end
     end
     end


     local lookup     = build_hero_lookup()
     local compact = mw.text.trim(frame.args["compact"] or "") ~= ""
 
    local collapsed = mw.text.trim(frame.args["collapsed"] or ""):lower()
 
     local views, order = build_views()
     local spec      = extra_columns[stat]
     local spec      = extra_columns[stat]
     local headers    = spec and spec.headers or {}
     local headers    = spec and spec.headers or {}
     local get_cells  = spec and spec.get_cells or function() return {} end
     local get_cells  = spec and spec.get_cells or function() return {} end
     local stat_notes = human_notes[stat]
     local stat_notes = Notes[stat]
 
    local abilities = GameData.get_entities(GameData.Dataset.ABILITIES, internal_keys)


    -- Keep only abilities belonging to active, released heroes
     local filtered = {}
     local filtered = {}
     for _, ability in ipairs(abilities) do
     for _, key in ipairs(order) do
        local key = ability["Key"]
         if p.is_member(stat, key) then
         if key and lookup[key] then
             table.insert(filtered, key)
             table.insert(filtered, ability)
         end
         end
     end
     end


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


     -- Sort by hero name, then ability slot number
     local lists_url = mw.uri.fullUrl("Module:AbilityTable/Lists")
    table.sort(filtered, function(a, b)
    local notes_url = mw.uri.fullUrl("Module:AbilityTable/Notes", { action = "edit" })
         local ia = lookup[a["Key"]]
    local disclaimer = frame:preprocess(
        local ib = lookup[b["Key"]]
        '<div class="ability-table-disclaimer">'
        if ia.heroName ~= ib.heroName then
        .. 'This is a dynamic list '
             return ia.heroName < ib.heroName
        .. '<span class="ability-table-list-name">"' .. stat .. '"</span>. '
        .. '[' .. tostring(lists_url) .. ' Edit contents]'
        .. ' · '
        .. '[' .. tostring(notes_url) .. ' Edit notes].'
        .. '</div>'
    )
 
    if compact then
        local hero_order    = {}
        local hero_abilities = {}
        for _, key in ipairs(filtered) do
            local hero = views[key].heroName
            if not hero_abilities[hero] then
                table.insert(hero_order, hero)
                hero_abilities[hero] = {}
            end
            table.insert(hero_abilities[hero], views[key].view)
         end
 
        local items = {}
        for _, hero in ipairs(hero_order) do
            local hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { hero } }
            local ability_parts = {}
            for _, ability in ipairs(hero_abilities[hero]) do
                local name = ability["Name"]
                local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { name } }
 
                local extra = get_cells(ability)
                enrich_notes_compact(extra, stat_notes and stat_notes[name])
 
                local note = extra["Notes"]
                if note then
                    table.insert(ability_parts, ability_cell .. " (" .. note .. ")")
                else
                    table.insert(ability_parts, ability_cell)
                end
            end
             table.insert(items, "<li>" .. hero_cell .. ": " .. table.concat(ability_parts, ", ") .. "</li>")
         end
         end
        return ia.abilityNum < ib.abilityNum
    end)


    -- Determine final header list, respecting hide_set.
        return disclaimer .. "<ul>" .. table.concat(items) .. "</ul>"
    -- "Notes" is included if declared in headers or if human notes exist,
     end
     -- but only if not hidden. Either way it appears exactly once at the end.
 
     local has_notes_header = false
     local has_notes_header = false
     local final_headers = {}
     local final_headers = {}
Line 289: Line 305:
     end
     end
     local needs_notes = (has_notes_header or stat_notes ~= nil) and not hide_set["Notes"]
     local needs_notes = (has_notes_header or stat_notes ~= nil) and not hide_set["Notes"]
     if needs_notes then
     if needs_notes then table.insert(final_headers, "Notes") end
        table.insert(final_headers, "Notes")
 
    local table_class = 'wikitable sortable ability-table'
    if collapsed ~= "" then
        table_class = table_class .. ' mw-collapsible'
        if collapsed == "yes" or collapsed == "true" or collapsed == "1" then
            table_class = table_class .. ' mw-collapsed'
        end
     end
     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>"
    )
    -- Render wikitable
     local out = {}
     local out = {}
     table.insert(out, disclaimer)
     table.insert(out, disclaimer)
     table.insert(out, '{| class="wikitable sortable ability-table"')
     table.insert(out, '{| class="' .. table_class .. '"')


     local header_cells = '! class="ability-table-col-hero" | Hero'
     local header_row = '! class="ability-table-col-hero" | Hero'
                      .. ' !! class="ability-table-col-ability" | Ability'
                    .. ' !! class="ability-table-col-ability" | Ability'
     for _, h in ipairs(final_headers) do
     for _, h in ipairs(final_headers) do
         local cls = "ability-table-col-" .. h:lower():gsub("%s+", "-")
         header_row = header_row .. ' !! class="ability-table-col-'
        header_cells = header_cells .. ' !! class="' .. cls .. '" | ' .. h
            .. h:lower():gsub("%s+", "-") .. '" | ' .. h
    end
    table.insert(out, header_row)
 
        local hero_counts = {}
    for _, key in ipairs(filtered) do
        local hero = views[key].heroName
        hero_counts[hero] = (hero_counts[hero] or 0) + 1
     end
     end
    table.insert(out, header_cells)


     for _, ability in ipairs(filtered) do
    local hero_seen = {}
         local info = lookup[ability["Key"]]
 
     for _, key in ipairs(filtered) do
         local entry  = views[key]
        local ability = entry.view
        local hero    = entry.heroName


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


         local extra = get_cells(ability)
         local extra = get_cells(ability)
        enrich_notes_full(extra, stat_notes and stat_notes[ability["Name"]])
        local row
        if not hero_seen[hero] then
            local hero_cell = frame:expandTemplate{
                title = "HeroIcon",
                args = { hero }
            }
            if hero_counts[hero] > 1 then
                row = '| rowspan="' .. hero_counts[hero] .. '" class="ability-table-cell-hero" | '
                    .. hero_cell
                    .. ' || class="ability-table-cell-ability" | ' .. ability_cell
            else
                row = '| class="ability-table-cell-hero" | '
                    .. hero_cell
                    .. ' || class="ability-table-cell-ability" | ' .. ability_cell
            end


        -- Append human note to any renderer-generated note
            hero_seen[hero] = true
         if stat_notes and stat_notes[ability["Name"]] then
         else
             local human = stat_notes[ability["Name"]]
             row = '| class="ability-table-cell-ability" | ' .. ability_cell
            extra["Notes"] = extra["Notes"] and (extra["Notes"] .. " " .. human) or human
         end
         end


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


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



Latest revision as of 02:53, 22 August 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

[edit source]

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

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

local p = {}

local extra_columns = {
    ["barrier"] = {
        headers   = { "Barrier" },
        get_cells = Renderers.RenderBarrier,
    },
    ["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 = Renderers.RenderSpiritScaling,
    },
    ["healreduce"] = {
        headers   = { "Heal Reduction", "Notes" },
        get_cells = Renderers.RenderHealReduce,
    },
    ["moveslow"] = {
        headers   = { "Move Slow", "Notes" },
        get_cells = Renderers.RenderMoveSlow,
    },
    ["dashslow"] = {
        headers   = { "Dash Slow", "Notes" },
        get_cells = Renderers.RenderDashSlow,
    },
    ["extradebuffresist"] = {
        headers   = { "Debuff Resist" },
        get_cells = Renderers.RenderExtraDebuffResist,
    },
}

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

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

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

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

-- "Ability View" is a main-record + any relevant sub-ability records.
-- ============================================================

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

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

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

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

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

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

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

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

    return _views, _order
end

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

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

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

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

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

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

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

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

    return GameData.entity_matches(view, props)
end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    local hero_seen = {}

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

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

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

        local row

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

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

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

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

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

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

return p