Module:AbilityTable: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Vergir (talk | contribs)
Add debuff stat with Effects/Duration columns, manual_entries mechanism, prop_to_effect map (with help from vergir-bot LLM)
Vergir (talk | contribs)
Add hero field to manual_entries; add DebuffAccuracy/TimeSlowDuration/LateCheckoutStun props; resolve manual entry heroes via name lookup (with help from vergir-bot LLM)
Line 11: Line 11:
         "PetrifyDuration", "HexDuration", "ImmobilizeDuration", "LiftDuration",
         "PetrifyDuration", "HexDuration", "ImmobilizeDuration", "LiftDuration",
         "SlowPercent", "DebuffDuration", "HoldInPlaceDuration",
         "SlowPercent", "DebuffDuration", "HoldInPlaceDuration",
        "DebuffAccuracy", "TimeSlowDuration", "LateCheckoutStun",
     },
     },
}
}
Line 24: Line 25:
}
}


-- Manually specified entries for abilities that have no matching props,
-- Manually specified entries for abilities whose effects cannot be inferred from
-- or whose effects cannot be inferred from data alone.
-- data alone (e.g. engine-side CC with no matching data fields).
-- Each entry: { effects = { "EffectName", ... }, duration = <number or nil> }
-- Each entry requires:
-- effects list may include "(TN)" suffix for upgrade-only effects: "Stun (T3)"
--  hero    = "Hero Name"  (as it appears on the wiki, used for HeroIcon)
-- duration nil renders as "—".
--  effects = { "EffectName", ... } (supports "Effect (TN)" for upgrade-only)
--   duration = <number or nil>        (nil renders as "—")
local manual_entries = {
local manual_entries = {
     ["debuff"] = {
     ["debuff"] = {
         ["Combo"] = {
         ["Combo"] = {
            hero    = "Mo & Krill",
             effects  = { "Stun" },
             effects  = { "Stun" },
             duration = 2.4,
             duration = 2.4,
         },
         },
         ["Sand Blast"] = {
         ["Sand Blast"] = {
            hero    = "Mo & Krill",
             effects  = { "Disarm" },
             effects  = { "Disarm" },
             duration = 2.5,
             duration = 2.5,
         },
         },
         ["Spirit Lasso"] = {
         ["Spirit Lasso"] = {
            hero    = "Holliday",
             effects  = { "Stun" },
             effects  = { "Stun" },
             duration = 2.25,
             duration = 2.25,
Line 140: Line 145:
end
end


-- Finds the base light melee scale value: the first top-level property
-- whose Scale.Type is "melee" and whose scale value is > 0.
local function find_base_melee_scale(ability)
local function find_base_melee_scale(ability)
     for _, v in pairs(ability) do
     for _, v in pairs(ability) do
Line 158: Line 161:
end
end


-- Scans all upgrade tiers for melee-scale changes. Upgrade objects are
-- additive in the engine, so the final scale = base + upgrade delta.
-- Returns a list of note strings, one per relevant upgrade tier found.
local function build_melee_upgrade_notes(ability, base_melee_scale)
local function build_melee_upgrade_notes(ability, base_melee_scale)
     local upgrades = ability["Upgrades"]
     local upgrades = ability["Upgrades"]
     if type(upgrades) ~= "table" then return {} end
     if type(upgrades) ~= "table" then return {} end
     local notes = {}
     local notes = {}
     for i, tier in ipairs(upgrades) do
     for i, tier in ipairs(upgrades) do
         if type(tier) == "table" then
         if type(tier) == "table" then
Line 175: Line 173:
                         local stype = scale["Type"]
                         local stype = scale["Type"]
                         local delta = scale["Value"]
                         local delta = scale["Value"]
                         if stype == "heavy_melee" and delta > 0 then
                         if stype == "heavy_melee" and delta > 0 then
                             table.insert(notes, "T" .. i .. " upgrade changes scaling to ×"
                             table.insert(notes, "T" .. i .. " upgrade changes scaling to ×"
                                 .. delta .. " of Heavy Melee damage.")
                                 .. delta .. " of Heavy Melee damage.")
                         elseif stype == "melee" and delta ~= 0 and base_melee_scale then
                         elseif stype == "melee" and delta ~= 0 and base_melee_scale then
                             local final = base_melee_scale + delta
                             local final = math.floor((base_melee_scale + delta) * 1000 + 0.5) / 1000
                            final = math.floor(final * 1000 + 0.5) / 1000
                             table.insert(notes, "T" .. i .. " upgrade adds ×"
                             table.insert(notes, "T" .. i .. " upgrade adds ×"
                                 .. delta .. " scaling (total ×" .. final .. ").")
                                 .. delta .. " scaling (total ×" .. final .. ").")
Line 190: Line 186:
         end
         end
     end
     end
     return notes
     return notes
end
end
Line 196: Line 191:
local function melee_cells(ability)
local function melee_cells(ability)
     local cells = {}
     local cells = {}
     local base_scale = find_base_melee_scale(ability)
     local base_scale = find_base_melee_scale(ability)
     if base_scale then
     if base_scale then cells["Light Melee Scaling"] = "×" .. base_scale end
        cells["Light Melee Scaling"] = "×" .. base_scale
    end
 
     local upgrade_notes = build_melee_upgrade_notes(ability, base_scale)
     local upgrade_notes = build_melee_upgrade_notes(ability, base_scale)
     if #upgrade_notes > 0 then
     if #upgrade_notes > 0 then cells["Notes"] = table.concat(upgrade_notes, " ") end
        cells["Notes"] = table.concat(upgrade_notes, " ")
    end
 
     return cells
     return cells
end
end


-- Returns the base duration value using priority: VenomDuration > DebuffDuration > AbilityDuration > BurnDuration.
-- Also returns the field name that was found (for upgrade scanning).
local function find_base_duration(ability)
local function find_base_duration(ability)
     for _, field in ipairs({ "VenomDuration", "DebuffDuration", "AbilityDuration", "BurnDuration" }) do
     for _, field in ipairs({ "VenomDuration", "DebuffDuration", "AbilityDuration", "BurnDuration" }) do
         local v = num_val(ability[field])
         local v = num_val(ability[field])
         if v and v > 0 then
         if v and v > 0 then return v, field end
            return v, field
        end
     end
     end
     return nil, nil
     return nil, nil
Line 225: Line 209:
     local cells = {}
     local cells = {}
     local notes = {}
     local notes = {}
     local base_dur, dur_field = find_base_duration(ability)
     local base_dur, dur_field = find_base_duration(ability)


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


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


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


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


-- Maps AbilityData property names to human-readable effect names for the debuff table.
-- Maps AbilityData property names to human-readable effect names for the debuff table.
-- Properties listed here are the canonical CC props; DebuffDuration is intentionally
-- DebuffDuration is absent: it signals "(See notes)" on Duration rather than a named effect.
-- absent because it renders as "(See notes)" rather than a named effect.
local debuff_prop_to_effect = {
local debuff_prop_to_effect = {
     StunDuration        = "Stun",
     StunDuration        = "Stun",
Line 344: Line 297:
     SlowPercent        = "Slow",
     SlowPercent        = "Slow",
     HoldInPlaceDuration = "Stop",
     HoldInPlaceDuration = "Stop",
    DebuffAccuracy      = "Inaccuracy",
    TimeSlowDuration    = "Time Slow",
    LateCheckoutStun    = "Stun",
}
}


-- Duration fields corresponding to each CC prop (for reading the numeric value).
-- Duration fields for each CC prop (the field whose numeric value is the duration).
-- SlowPercent and SilenceDebuff/HoldInPlaceDuration have separate duration fields.
-- Props that are themselves duration fields map to themselves.
local debuff_prop_duration_field = {
-- Props that are flags or percentages map to a separate duration field.
local debuff_prop_to_dur_field = {
     StunDuration        = "StunDuration",
     StunDuration        = "StunDuration",
     SleepDuration      = "SleepDuration",
     SleepDuration      = "SleepDuration",
     SilenceDuration    = "SilenceDuration",
     SilenceDuration    = "SilenceDuration",
     SilenceDebuff      = "SilenceDuration",   -- boolean flag; duration in SilenceDuration
     SilenceDebuff      = "SilenceDuration",
     PetrifyDuration    = "PetrifyDuration",
     PetrifyDuration    = "PetrifyDuration",
     HexDuration        = "HexDuration",
     HexDuration        = "HexDuration",
Line 359: Line 316:
     SlowPercent        = "SlowDuration",
     SlowPercent        = "SlowDuration",
     HoldInPlaceDuration = "HoldInPlaceDuration",
     HoldInPlaceDuration = "HoldInPlaceDuration",
    DebuffAccuracy      = "AbilityDuration",
    TimeSlowDuration    = "TimeSlowDuration",
    LateCheckoutStun    = "LateCheckoutStun",
}
}


Line 364: Line 324:
     local cells = {}
     local cells = {}


     -- If a manual entry exists for this ability, use it directly.
     -- Manual entry takes full precedence.
     local manual = stat_manual and stat_manual[ability["Name"]]
     local manual = stat_manual and stat_manual[ability["Name"]]
     if manual then
     if manual then
Line 375: Line 335:
         return cells
         return cells
     end
     end
    -- Collect effects from base props and upgrades.
    -- effects_seen: effect_name -> { tier = N or nil, dur_field, value }
    --  tier nil = present at base; tier N = first appears at upgrade N
    local effects_seen = {}  -- ordered list of { name, tier }
    local effects_set  = {}  -- name -> true, for dedup


     local CC_PROPS = {
     local CC_PROPS = {
         "StunDuration", "SleepDuration", "SilenceDuration", "SilenceDebuff",
         "StunDuration", "SleepDuration", "SilenceDuration", "SilenceDebuff",
         "PetrifyDuration", "HexDuration", "ImmobilizeDuration", "LiftDuration",
         "PetrifyDuration", "HexDuration", "ImmobilizeDuration", "LiftDuration",
         "SlowPercent", "HoldInPlaceDuration",
         "SlowPercent", "HoldInPlaceDuration", "DebuffAccuracy",
        "TimeSlowDuration", "LateCheckoutStun",
     }
     }


     -- Check base fields
     local effects_seen = {}  -- ordered list of { name, tier }
    local effects_set  = {}  -- effect name -> true, for dedup
 
    -- Base fields
     for _, prop in ipairs(CC_PROPS) do
     for _, prop in ipairs(CC_PROPS) do
         local val = ability[prop]
         local val   = ability[prop]
         local effect = debuff_prop_to_effect[prop]
         local effect = debuff_prop_to_effect[prop]
         if val and val ~= 0 and effect and not effects_set[effect] then
         if val and val ~= 0 and effect and not effects_set[effect] then
Line 398: Line 356:
     end
     end


     -- Check upgrades
     -- Upgrade fields
     local upgrades = ability["Upgrades"]
     local upgrades = ability["Upgrades"]
     if type(upgrades) == "table" then
     if type(upgrades) == "table" then
Line 404: Line 362:
             if type(tier) == "table" then
             if type(tier) == "table" then
                 for _, prop in ipairs(CC_PROPS) do
                 for _, prop in ipairs(CC_PROPS) do
                     local val = tier[prop]
                     local val   = tier[prop]
                     local effect = debuff_prop_to_effect[prop]
                     local effect = debuff_prop_to_effect[prop]
                     if val and val ~= 0 and effect and not effects_set[effect] then
                     if val and val ~= 0 and effect and not effects_set[effect] then
Line 415: Line 373:
     end
     end


     -- Check DebuffDuration separately (adds "(See notes)" sentinel if present)
     -- DebuffDuration: present anywhere → Duration shows "(See notes)"
     local has_debuff_dur = (ability["DebuffDuration"] and ability["DebuffDuration"] ~= 0)
     local has_debuff_dur = (ability["DebuffDuration"] and ability["DebuffDuration"] ~= 0)
     if not has_debuff_dur and type(upgrades) == "table" then
     if not has_debuff_dur and type(upgrades) == "table" then
Line 426: Line 384:
     end
     end


     -- Build Effects cell string
     -- Effects cell
     local effect_parts = {}
     local effect_parts = {}
     for _, e in ipairs(effects_seen) do
     for _, e in ipairs(effects_seen) do
         if e.tier then
         table.insert(effect_parts, e.tier and (e.name .. " (T" .. e.tier .. ")") or e.name)
            table.insert(effect_parts, e.name .. " (T" .. e.tier .. ")")
        else
            table.insert(effect_parts, e.name)
        end
     end
     end
     if #effect_parts > 0 then
     if #effect_parts > 0 then
Line 439: Line 393:
     end
     end


     -- Build Duration cell.
     -- Duration cell: collect distinct non-DebuffDuration duration fields (base or upgrade)
    -- Rule: find all non-DebuffDuration duration fields present (base or upgrade).
    -- If exactly one distinct field found -> use its base or first-appearing value.
    -- If DebuffDuration present and it's the only duration -> "(See notes)".
    -- If multiple distinct fields -> "(See notes)".
    -- If none -> nil (renders as "—").
    local dur_fields_found = {}  -- field_name -> value
     local DUR_FIELDS = {
     local DUR_FIELDS = {
         "StunDuration", "SleepDuration", "SilenceDuration", "PetrifyDuration",
         "StunDuration", "SleepDuration", "SilenceDuration", "PetrifyDuration",
         "HexDuration", "ImmobilizeDuration", "LiftDuration", "SlowDuration",
         "HexDuration", "ImmobilizeDuration", "LiftDuration", "SlowDuration",
         "HoldInPlaceDuration",
         "HoldInPlaceDuration", "AbilityDuration", "TimeSlowDuration", "LateCheckoutStun",
     }
     }
 
    local dur_found = {}
     for _, f in ipairs(DUR_FIELDS) do
     for _, f in ipairs(DUR_FIELDS) do
         local val = num_val(ability[f])
         local val = num_val(ability[f])
         if val and val > 0 then
         if val and val > 0 then dur_found[f] = val end
            dur_fields_found[f] = val
        end
     end
     end
    -- Also check upgrades for any duration field not present at base
     if type(upgrades) == "table" then
     if type(upgrades) == "table" then
         for _, tier in ipairs(upgrades) do
         for _, tier in ipairs(upgrades) do
             if type(tier) == "table" then
             if type(tier) == "table" then
                 for _, f in ipairs(DUR_FIELDS) do
                 for _, f in ipairs(DUR_FIELDS) do
                     if not dur_fields_found[f] then
                     if not dur_found[f] then
                         local val = num_val(tier[f])
                         local val = num_val(tier[f])
                         if val and val > 0 then
                         if val and val > 0 then dur_found[f] = val end
                            dur_fields_found[f] = val
                        end
                     end
                     end
                 end
                 end
Line 474: Line 417:
     end
     end


     local distinct_dur_count = 0
     -- AbilityDuration is only meaningful as a duration source when DebuffAccuracy is the
     local sole_dur_val      = nil
    -- sole CC prop (Call Bell) — otherwise it's too generic. Remove it unless that's the case.
    for _, val in pairs(dur_fields_found) do
     local sole_cc_is_accuracy = (effects_set["Inaccuracy"] and not effects_set["Slow"]
        distinct_dur_count = distinct_dur_count + 1
        and not effects_set["Stun"] and not effects_set["Sleep"]
         sole_dur_val = val
        and not effects_set["Silence"] and not effects_set["Petrify"]
        and not effects_set["Hex"] and not effects_set["Root"]
        and not effects_set["Lift"] and not effects_set["Stop"]
        and not effects_set["Time Slow"])
    if not sole_cc_is_accuracy then
         dur_found["AbilityDuration"] = nil
     end
     end


     if distinct_dur_count == 1 then
    local count, sole_val = 0, nil
         cells["Duration"] = tostring(sole_dur_val)
    for _, val in pairs(dur_found) do count = count + 1; sole_val = val end
     elseif distinct_dur_count > 1 or has_debuff_dur then
 
     if count == 1 then
         cells["Duration"] = tostring(sole_val)
     elseif count > 1 or has_debuff_dur then
         cells["Duration"] = "(See notes)"
         cells["Duration"] = "(See notes)"
     end
     end
    -- distinct_dur_count == 0 and no debuff_dur -> Duration stays nil -> "—"


     return cells
     return cells
Line 512: Line 462:
-- ============================================================
-- ============================================================
-- Hero lookup (ability key -> { heroName, abilityNum })
-- Hero lookup (ability key -> { heroName, abilityNum })
-- Also builds a name-keyed lookup for manual entries.
-- ============================================================
-- ============================================================


local _hero_lookup = nil
local _hero_lookup     = nil -- key  -> { heroName, abilityNum }
local _name_lookup      = nil  -- name  -> { heroName, abilityNum }


local function build_hero_lookup()
local function build_hero_lookup()
     if _hero_lookup then return _hero_lookup end
     if _hero_lookup then return _hero_lookup, _name_lookup end
     _hero_lookup = {}
     _hero_lookup = {}
    _name_lookup = {}


     local hero_data = mw.loadJsonData("Data:HeroData.json")
     local hero_data = mw.loadJsonData("Data:HeroData.json")
Line 539: Line 492:
                 for slot_str, ability in pairs(hero_entry) do
                 for slot_str, ability in pairs(hero_entry) do
                     local slot_num = tonumber(slot_str)
                     local slot_num = tonumber(slot_str)
                     if slot_num and type(ability) == "table" and ability["Key"] then
                     if slot_num and type(ability) == "table" then
                        _hero_lookup[ability["Key"]] = {
                        local info = { heroName = hero_name, abilityNum = slot_num }
                            heroName  = hero_name,
                        if ability["Key"] then
                             abilityNum = slot_num,
                            _hero_lookup[ability["Key"]] = info
                         }
                        end
                        if ability["Name"] then
                             _name_lookup[ability["Name"]] = info
                         end
                     end
                     end
                 end
                 end
Line 550: Line 506:
     end
     end


     return _hero_lookup
     return _hero_lookup, _name_lookup
end
end


Line 565: Line 521:
     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 lookup, name_lookup = build_hero_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 {}
     local stat_notes = human_notes[stat]
     local stat_notes = human_notes[stat]
     local stat_manual = manual_entries[stat]
     local stat_manual = manual_entries[stat]


    -- Wrap the renderer to inject manual_entries for stats that need it
     local get_cells
     local get_cells
     if spec then
     if spec then
Line 596: Line 548:
     -- Keep only abilities belonging to active, released heroes
     -- Keep only abilities belonging to active, released heroes
     local filtered = {}
     local filtered = {}
    local filtered_names = {}
     for _, ability in ipairs(abilities) do
     for _, ability in ipairs(abilities) do
         local key = ability["Key"]
         local key = ability["Key"]
         if key and lookup[key] then
         if key and lookup[key] then
             table.insert(filtered, ability)
             table.insert(filtered, ability)
            filtered_names[ability["Name"]] = true
         end
         end
     end
     end


     -- Add manual entries whose ability key is not already in filtered
     -- Inject manual entries not already covered by prop-matching
     if stat_manual then
     if stat_manual then
        local filtered_names = {}
         for name, entry in pairs(stat_manual) do
        for _, ability in ipairs(filtered) do
            filtered_names[ability["Name"]] = true
        end
         for name, _ in pairs(stat_manual) do
             if not filtered_names[name] then
             if not filtered_names[name] then
                 -- Find the ability record by name
                 -- Look up hero and slot via the name-keyed lookup first,
                -- We search all abilities returned by get_entities with an empty key list
                 -- then fall back to the hero field on the entry.
                 -- instead: search the GameData for this name via a synthetic record
                 local info = name_lookup[name]
                 -- Since we can't query by name, we inject a minimal synthetic record
                 table.insert(filtered, {
                -- that debuff_cells will resolve via the manual entry
                    Name       = name,
                 table.insert(filtered, { Name = name, Key = "__manual__" .. name })
                    Key         = "__manual__",
                    _manualInfo = info or { heroName = entry.hero, abilityNum = 9999 },
                })
             end
             end
         end
         end
Line 625: Line 577:
     end
     end


     -- Sort by hero name, then ability slot number.
     -- Sort by hero name, then ability slot number
    -- Manual entries (key starts with __manual__) go at the end if not in lookup.
     table.sort(filtered, function(a, b)
     table.sort(filtered, function(a, b)
         local ia = lookup[a["Key"]]
         local ia = lookup[a["Key"]] or a._manualInfo
         local ib = lookup[b["Key"]]
         local ib = lookup[b["Key"]] or b._manualInfo
         if not ia and not ib then return (a["Name"] or "") < (b["Name"] or "") end
         if not ia and not ib then return (a["Name"] or "") < (b["Name"] or "") end
         if not ia then return false end
         if not ia then return false end
         if not ib then return true end
         if not ib then return true end
         if ia.heroName ~= ib.heroName then
         if ia.heroName ~= ib.heroName then return ia.heroName < ib.heroName end
            return ia.heroName < ib.heroName
        end
         return ia.abilityNum < ib.abilityNum
         return ia.abilityNum < ib.abilityNum
     end)
     end)


     -- Determine final header list, respecting hide_set.
     -- Determine final headers
     local has_notes_header = false
     local has_notes_header = false
     local final_headers = {}
     local final_headers = {}
Line 650: Line 599:
     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")
    end


     -- Disclaimer
     -- Disclaimer
Line 664: Line 611:
     )
     )


    -- 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="wikitable sortable ability-table"')


     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
     end
     table.insert(out, header_cells)
     table.insert(out, header_row)


     for _, ability in ipairs(filtered) do
     for _, ability in ipairs(filtered) do
         local info = lookup[ability["Key"]]
         local info = lookup[ability["Key"]] or ability._manualInfo


         local hero_cell, ability_cell
         local hero_cell, ability_cell
         if info then
         if info and info.heroName then
             hero_cell = frame:expandTemplate{
             hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { info.heroName } }
                title = "HeroIcon",
                args = { info.heroName }
            }
            ability_cell = frame:expandTemplate{
                title = "AbilityIcon",
                args  = { ability["Name"] }
            }
         else
         else
            -- Manual entry with no hero lookup: render name only
             hero_cell = "—"
             hero_cell   = "—"
            ability_cell = frame:expandTemplate{
                title = "AbilityIcon",
                args  = { ability["Name"] }
            }
         end
         end
        ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { ability["Name"] } }


         local extra = get_cells(ability)
         local extra = get_cells(ability)


        -- Append human note to any renderer-generated note
         if stat_notes and stat_notes[ability["Name"]] then
         if stat_notes and stat_notes[ability["Name"]] then
             local human = stat_notes[ability["Name"]]
             local human = stat_notes[ability["Name"]]
Line 707: Line 641:
         end
         end


         local row_cells = '| class="ability-table-cell-hero" | '    .. hero_cell
         local row = '| class="ability-table-cell-hero" | '    .. hero_cell
                      .. ' || class="ability-table-cell-ability" | ' .. ability_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]
Line 715: Line 649:
             if value ~= nil then
             if value ~= nil then
                 local suffix = column_suffix[header] or ""
                 local suffix = column_suffix[header] or ""
                 -- Don't append suffix to already-complete strings like "(See notes)"
                 content = (type(value) == "string" and value:sub(1,1) == "(")
                if type(value) == "string" and value:sub(1, 1) == "(" then
                     and value
                     content = value
                     or  (value .. suffix)
                else
                     content = value .. suffix
                end
             else
             else
                 content = "—"
                 content = "—"
             end
             end
             row_cells = row_cells .. ' || class="' .. cls .. '" | ' .. content
             row = row .. ' || class="' .. cls .. '" | ' .. content
         end
         end


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



Revision as of 10:36, 13 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" },
    ["debuff"]     = {
        "StunDuration", "SleepDuration", "SilenceDuration", "SilenceDebuff",
        "PetrifyDuration", "HexDuration", "ImmobilizeDuration", "LiftDuration",
        "SlowPercent", "DebuffDuration", "HoldInPlaceDuration",
        "DebuffAccuracy", "TimeSlowDuration", "LateCheckoutStun",
    },
}

-- 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.",
    },
}

-- Manually specified entries for abilities whose effects cannot be inferred from
-- data alone (e.g. engine-side CC with no matching data fields).
-- Each entry requires:
--   hero     = "Hero Name"  (as it appears on the wiki, used for HeroIcon)
--   effects  = { "EffectName", ... }  (supports "Effect (TN)" for upgrade-only)
--   duration = <number or nil>        (nil renders as "—")
local manual_entries = {
    ["debuff"] = {
        ["Combo"] = {
            hero     = "Mo & Krill",
            effects  = { "Stun" },
            duration = 2.4,
        },
        ["Sand Blast"] = {
            hero     = "Mo & Krill",
            effects  = { "Disarm" },
            duration = 2.5,
        },
        ["Spirit Lasso"] = {
            hero     = "Holliday",
            effects  = { "Stun" },
            duration = 2.25,
        },
    },
}

-- Unit strings appended to cell values by column name.
-- Values starting with "(" are treated as already-complete strings and
-- the suffix is NOT appended (e.g. "(See notes)" stays as-is).
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

-- Maps AbilityData property names to human-readable effect names for the debuff table.
-- DebuffDuration is absent: it signals "(See notes)" on Duration rather than a named effect.
local debuff_prop_to_effect = {
    StunDuration        = "Stun",
    SleepDuration       = "Sleep",
    SilenceDuration     = "Silence",
    SilenceDebuff       = "Silence",
    PetrifyDuration     = "Petrify",
    HexDuration         = "Hex",
    ImmobilizeDuration  = "Root",
    LiftDuration        = "Lift",
    SlowPercent         = "Slow",
    HoldInPlaceDuration = "Stop",
    DebuffAccuracy      = "Inaccuracy",
    TimeSlowDuration    = "Time Slow",
    LateCheckoutStun    = "Stun",
}

-- Duration fields for each CC prop (the field whose numeric value is the duration).
-- Props that are themselves duration fields map to themselves.
-- Props that are flags or percentages map to a separate duration field.
local debuff_prop_to_dur_field = {
    StunDuration        = "StunDuration",
    SleepDuration       = "SleepDuration",
    SilenceDuration     = "SilenceDuration",
    SilenceDebuff       = "SilenceDuration",
    PetrifyDuration     = "PetrifyDuration",
    HexDuration         = "HexDuration",
    ImmobilizeDuration  = "ImmobilizeDuration",
    LiftDuration        = "LiftDuration",
    SlowPercent         = "SlowDuration",
    HoldInPlaceDuration = "HoldInPlaceDuration",
    DebuffAccuracy      = "AbilityDuration",
    TimeSlowDuration    = "TimeSlowDuration",
    LateCheckoutStun    = "LateCheckoutStun",
}

local function debuff_cells(ability, stat_manual)
    local cells = {}

    -- Manual entry takes full precedence.
    local manual = stat_manual and stat_manual[ability["Name"]]
    if manual then
        if manual.effects and #manual.effects > 0 then
            cells["Effects"] = table.concat(manual.effects, ", ")
        end
        if manual.duration then
            cells["Duration"] = tostring(manual.duration)
        end
        return cells
    end

    local CC_PROPS = {
        "StunDuration", "SleepDuration", "SilenceDuration", "SilenceDebuff",
        "PetrifyDuration", "HexDuration", "ImmobilizeDuration", "LiftDuration",
        "SlowPercent", "HoldInPlaceDuration", "DebuffAccuracy",
        "TimeSlowDuration", "LateCheckoutStun",
    }

    local effects_seen = {}  -- ordered list of { name, tier }
    local effects_set  = {}  -- effect name -> true, for dedup

    -- Base fields
    for _, prop in ipairs(CC_PROPS) do
        local val    = ability[prop]
        local effect = debuff_prop_to_effect[prop]
        if val and val ~= 0 and effect and not effects_set[effect] then
            table.insert(effects_seen, { name = effect, tier = nil })
            effects_set[effect] = true
        end
    end

    -- Upgrade fields
    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(CC_PROPS) do
                    local val    = tier[prop]
                    local effect = debuff_prop_to_effect[prop]
                    if val and val ~= 0 and effect and not effects_set[effect] then
                        table.insert(effects_seen, { name = effect, tier = i })
                        effects_set[effect] = true
                    end
                end
            end
        end
    end

    -- DebuffDuration: present anywhere → Duration shows "(See notes)"
    local has_debuff_dur = (ability["DebuffDuration"] and ability["DebuffDuration"] ~= 0)
    if not has_debuff_dur and type(upgrades) == "table" then
        for _, tier in ipairs(upgrades) do
            if type(tier) == "table" and tier["DebuffDuration"] and tier["DebuffDuration"] ~= 0 then
                has_debuff_dur = true
                break
            end
        end
    end

    -- Effects cell
    local effect_parts = {}
    for _, e in ipairs(effects_seen) do
        table.insert(effect_parts, e.tier and (e.name .. " (T" .. e.tier .. ")") or e.name)
    end
    if #effect_parts > 0 then
        cells["Effects"] = table.concat(effect_parts, ", ")
    end

    -- Duration cell: collect distinct non-DebuffDuration duration fields (base or upgrade)
    local DUR_FIELDS = {
        "StunDuration", "SleepDuration", "SilenceDuration", "PetrifyDuration",
        "HexDuration", "ImmobilizeDuration", "LiftDuration", "SlowDuration",
        "HoldInPlaceDuration", "AbilityDuration", "TimeSlowDuration", "LateCheckoutStun",
    }
    local dur_found = {}
    for _, f in ipairs(DUR_FIELDS) do
        local val = num_val(ability[f])
        if val and val > 0 then dur_found[f] = val end
    end
    if type(upgrades) == "table" then
        for _, tier in ipairs(upgrades) do
            if type(tier) == "table" then
                for _, f in ipairs(DUR_FIELDS) do
                    if not dur_found[f] then
                        local val = num_val(tier[f])
                        if val and val > 0 then dur_found[f] = val end
                    end
                end
            end
        end
    end

    -- AbilityDuration is only meaningful as a duration source when DebuffAccuracy is the
    -- sole CC prop (Call Bell) — otherwise it's too generic. Remove it unless that's the case.
    local sole_cc_is_accuracy = (effects_set["Inaccuracy"] and not effects_set["Slow"]
        and not effects_set["Stun"] and not effects_set["Sleep"]
        and not effects_set["Silence"] and not effects_set["Petrify"]
        and not effects_set["Hex"] and not effects_set["Root"]
        and not effects_set["Lift"] and not effects_set["Stop"]
        and not effects_set["Time Slow"])
    if not sole_cc_is_accuracy then
        dur_found["AbilityDuration"] = nil
    end

    local count, sole_val = 0, nil
    for _, val in pairs(dur_found) do count = count + 1; sole_val = val end

    if count == 1 then
        cells["Duration"] = tostring(sole_val)
    elseif count > 1 or has_debuff_dur then
        cells["Duration"] = "(See 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,
    },
    ["debuff"] = {
        headers   = { "Effects", "Duration" },
        get_cells = debuff_cells,
    },
}

-- ============================================================
-- Hero lookup (ability key -> { heroName, abilityNum })
-- Also builds a name-keyed lookup for manual entries.
-- ============================================================

local _hero_lookup      = nil  -- key   -> { heroName, abilityNum }
local _name_lookup      = nil  -- name  -> { heroName, abilityNum }

local function build_hero_lookup()
    if _hero_lookup then return _hero_lookup, _name_lookup end
    _hero_lookup = {}
    _name_lookup = {}

    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
                            _hero_lookup[ability["Key"]] = info
                        end
                        if ability["Name"] then
                            _name_lookup[ability["Name"]] = info
                        end
                    end
                end
            end
        end
    end

    return _hero_lookup, _name_lookup
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 lookup, name_lookup = build_hero_lookup()
    local spec        = extra_columns[stat]
    local headers     = spec and spec.headers or {}
    local stat_notes  = human_notes[stat]
    local stat_manual = manual_entries[stat]

    local get_cells
    if spec then
        if stat == "debuff" then
            get_cells = function(ability) return debuff_cells(ability, stat_manual) end
        else
            get_cells = spec.get_cells
        end
    else
        get_cells = function() return {} end
    end

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

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

    -- Inject manual entries not already covered by prop-matching
    if stat_manual then
        for name, entry in pairs(stat_manual) do
            if not filtered_names[name] then
                -- Look up hero and slot via the name-keyed lookup first,
                -- then fall back to the hero field on the entry.
                local info = name_lookup[name]
                table.insert(filtered, {
                    Name        = name,
                    Key         = "__manual__",
                    _manualInfo = info or { heroName = entry.hero, abilityNum = 9999 },
                })
            end
        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 = lookup[a["Key"]] or a._manualInfo
        local ib = lookup[b["Key"]] or b._manualInfo
        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)

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

    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 = lookup[ability["Key"]] or ability._manualInfo

        local hero_cell, ability_cell
        if info and info.heroName then
            hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { info.heroName } }
        else
            hero_cell = "—"
        end
        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