Module:AbilityTable: Difference between revisionsGive feedback
Jump to navigation
Jump to search
Add compact parameter: one row per hero, abilities comma-separated, no extra columns (with help from vergir-bot LLM) |
Refactor compact mode: ul/li output, hero name as text, notes inline after each ability in parentheses (with help from vergir-bot LLM) |
||
| Line 22: | Line 22: | ||
} | } | ||
-- Editor-authored notes appended to the Notes column. | -- Editor-authored notes appended to the Notes column (normal mode) or | ||
-- inline after the ability name in parentheses (compact mode). | |||
local human_notes = { | local human_notes = { | ||
["charges"] = { | ["charges"] = { | ||
| Line 31: | Line 32: | ||
}, | }, | ||
["debuffresist"] = { | ["debuffresist"] = { | ||
["Aura of Suffering"] | ["Aura of Suffering"] = "T1 slow, T3 extra damage. The effect is negligible.", | ||
["Lightning Ball"] | ["Lightning Ball"] = "T2 Slow. The effect is negligible.", | ||
["Static Charge"] | ["Static Charge"] = "Stun duration. Delay before stun is not affected.", | ||
["Enchanter's Satchel"] = "T3 Slow.", | ["Enchanter's Satchel"] = "T3 Slow.", | ||
["Itani Lo Sahn"] = "Despite being described as \"debuff\", it can not be reduced with debuff resist.", | ["Itani Lo Sahn"] = "Despite being described as \"debuff\", it can not be reduced with debuff resist.", | ||
["Scorn"] | ["Scorn"] = "T3 extra damage from Mo & Krill.", | ||
["Hotel Guest"] | ["Hotel Guest"] = "Only T2 late check-out stun can be reduced.", | ||
["Jar of Dead"] | ["Jar of Dead"] = "T2 slow.", | ||
["Grapple Arm"] | ["Grapple Arm"] = "T1 extra damage from Bebop.", | ||
["Binding Word"] | ["Binding Word"] = "Immobilize Duration. Escape time is not affected.", | ||
["Power Slash"] | ["Power Slash"] = "T1 slow.", | ||
["Kinetic Pulse"] | ["Kinetic Pulse"] = "T2 bullet resist reduction and slow.", | ||
["Pain Battery"] | ["Pain Battery"] = "T1 slow.", | ||
["Power Surge"] | ["Power Surge"] = "T3 Spirit Resist reduction.", | ||
["Rain of Arrows"] | ["Rain of Arrows"] = "T2 slow.", | ||
["Soul Exchange"] | ["Soul Exchange"] = "T2 silence.", | ||
["Ground Strike"] | ["Ground Strike"] = "T2 slow.", | ||
["Flog"] | ["Flog"] = "T2 Fire rate slow.", | ||
}, | }, | ||
} | } | ||
| Line 55: | Line 56: | ||
local exclude_list = { | local exclude_list = { | ||
["debuffresist"] = { | ["debuffresist"] = { | ||
"Bookwyrm", -- has | "Bookwyrm", -- has debuffduration for some reason | ||
"Slam Fire", -- has debuffduration for some reason | "Slam Fire", -- has debuffduration for some reason | ||
"Splatter", -- has SlowPercent but no duration to shave off. | "Splatter", -- has SlowPercent but no duration to shave off. | ||
| Line 427: | Line 428: | ||
.. "</div>" | .. "</div>" | ||
) | ) | ||
if compact then | if compact then | ||
-- Compact mode: one | -- Compact mode: <ul> with one <li> per hero. | ||
-- Each item: "{HeroIcon}: {AbilityIcon} (note), {AbilityIcon}, ..." | |||
-- Group abilities by hero, preserving sort order. | -- Group abilities by hero, preserving sort order. | ||
local hero_order | local hero_order = {} | ||
local hero_abilities = {} -- heroName -> list of ability names | local hero_abilities = {} -- heroName -> list of ability names | ||
for _, ability in ipairs(filtered) do | for _, ability in ipairs(filtered) do | ||
| Line 450: | Line 446: | ||
end | end | ||
local items = {} | |||
for _, hero in ipairs(hero_order) do | for _, hero in ipairs(hero_order) do | ||
local hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { hero } } | local hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { hero } } | ||
local ability_parts = {} | local ability_parts = {} | ||
for _, name in ipairs(hero_abilities[hero]) do | for _, name in ipairs(hero_abilities[hero]) do | ||
local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { name } } | |||
local note = stat_notes and stat_notes[name] | |||
if note then | |||
table.insert(ability_parts, ability_cell .. " (" .. note .. ")") | |||
else | |||
table.insert(ability_parts, ability_cell) | |||
end | |||
end | end | ||
table.insert( | table.insert(items, "<li>" .. hero_cell .. ": " .. table.concat(ability_parts, ", ") .. "</li>") | ||
end | end | ||
return disclaimer .. "<ul>" .. table.concat(items) .. "</ul>" | |||
end | |||
-- Normal mode: wikitable with one row per ability and stat-specific columns. | |||
local has_notes_header = false | |||
local final_headers = {} | |||
for _, h in ipairs(headers) do | |||
if h == "Notes" then | |||
has_notes_header = true | |||
elseif not hide_set[h] then | |||
table.insert(final_headers, h) | |||
end | end | ||
end | |||
local needs_notes = (has_notes_header or stat_notes ~= nil) and not hide_set["Notes"] | |||
if needs_notes then table.insert(final_headers, "Notes") end | |||
local out = {} | |||
table.insert(out, disclaimer) | |||
table.insert(out, '{| class="wikitable sortable ability-table"') | |||
local header_row = '! class="ability-table-col-hero" | Hero' | |||
.. ' !! class="ability-table-col-ability" | Ability' | |||
for _, h in ipairs(final_headers) do | |||
header_row = header_row .. ' !! class="ability-table-col-' | |||
.. h:lower():gsub("%s+", "-") .. '" | ' .. h | |||
end | |||
table.insert(out, header_row) | |||
for _, ability in ipairs(filtered) do | |||
local info = key_lookup[ability["Key"]] | |||
local hero_cell | |||
if info then | |||
hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { info.heroName } } | |||
else | |||
hero_cell = "—" | |||
end | |||
local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { ability["Name"] } } | |||
local extra = get_cells(ability) | |||
if stat_notes and stat_notes[ability["Name"]] then | |||
local human = stat_notes[ability["Name"]] | |||
extra["Notes"] = extra["Notes"] and (extra["Notes"] .. " " .. human) or human | |||
end | |||
local row = '| class="ability-table-cell-hero" | ' .. hero_cell | |||
.. ' || class="ability-table-cell-ability" | ' .. ability_cell | |||
for _, header in ipairs(final_headers) do | |||
local value = extra[header] | |||
local cls = "ability-table-cell-" .. header:lower():gsub("%s+", "-") | |||
local content | |||
if value ~= nil then | |||
local suffix = column_suffix[header] or "" | |||
content = (type(value) == "string" and value:sub(1,1) == "(") | |||
and value or (value .. suffix) | |||
else | |||
content = "—" | |||
end | end | ||
row = row .. ' || class="' .. cls .. '" | ' .. content | |||
end | |||
table.insert(out, "|-") | |||
table.insert(out, row) | |||
end | end | ||
Revision as of 11:12, 15 May 2026
This module exposes two functions.
rendergenerates 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 byrenderand externally by Module:AbilityHints, so the ability-hint icons always stay in sync with these tables.
See also
- Template:AbilityTable — the template that invokes this module
- Module:AbilityTable/Lists — defines which properties each list matches on, and which abilities to exclude
- Module:AbilityTable/Notes — editor-authored notes displayed in the Notes column
- Module:AbilityTable/ComplexRenderers — custom column renderers for lists that need more than just Notes
- Module:AbilityHints — reuses
is_memberto render interaction-hint icons on ability cards
-- Renders sortable wikitables of abilities filtered by a named stat.
-- Maps lowercase friendly stat names to internal AbilityData property keys or values.
local stat_to_props = {
["charges"] = { "AbilityCharges" },
["melee"] = { "melee", "heavy_melee" },
["wpndmg"] = { "melee", "heavy_melee", "weapon_damage_increase" },
["healreduce"] = { "HealAmpReceivePenaltyPercent", "HealAmpRegenPenaltyPercent", "DisableHealing" },
["debuffresist"] = {
-- Do not insert: AbilityDuration, TickRate, SlowDuration
-- they do more harm than good and include a lot of excessive abilities to the list
"StunDuration", "SleepDuration", "SilenceDuration", "SilenceDebuff",
"PetrifyDuration", "HexDuration", "ImmobilizeDuration", "LiftDuration",
"DebuffDuration", "HoldInPlaceDuration", "ExplodeDebuffDuration", "SlowPercent",
"DebuffAccuracy", "TimeSlowDuration", "LateCheckoutStun", "BulletDamageAmpDuration",
"BulletResistReduction", "BurnDuration", "EnemySlowPct", "MoveSlowPercent",
"GroundDashReductionPercent", "StealDuration", "MaxSlowDuration", "EffectDuration",
"BleedDuration", "VenomDuration",
-- can also just list abilities if they don't have a reusable property
"Hotel Guest", "Life Drain", "Combo", "Light Eater", "Spirit Lasso", "Card Trick"
},
}
-- Editor-authored notes appended to the Notes column (normal mode) or
-- inline after the ability name in parentheses (compact mode).
local human_notes = {
["charges"] = {
["Jar of Dead"] = "Charges by being near when anything dies.",
},
["melee"] = {
["Puddle Punch"] = "Can be parried.",
},
["debuffresist"] = {
["Aura of Suffering"] = "T1 slow, T3 extra damage. The effect is negligible.",
["Lightning Ball"] = "T2 Slow. The effect is negligible.",
["Static Charge"] = "Stun duration. Delay before stun is not affected.",
["Enchanter's Satchel"] = "T3 Slow.",
["Itani Lo Sahn"] = "Despite being described as \"debuff\", it can not be reduced with debuff resist.",
["Scorn"] = "T3 extra damage from Mo & Krill.",
["Hotel Guest"] = "Only T2 late check-out stun can be reduced.",
["Jar of Dead"] = "T2 slow.",
["Grapple Arm"] = "T1 extra damage from Bebop.",
["Binding Word"] = "Immobilize Duration. Escape time is not affected.",
["Power Slash"] = "T1 slow.",
["Kinetic Pulse"] = "T2 bullet resist reduction and slow.",
["Pain Battery"] = "T1 slow.",
["Power Surge"] = "T3 Spirit Resist reduction.",
["Rain of Arrows"] = "T2 slow.",
["Soul Exchange"] = "T2 silence.",
["Ground Strike"] = "T2 slow.",
["Flog"] = "T2 Fire rate slow.",
},
}
-- Listed by ability name. If adding please add a comment.
local exclude_list = {
["debuffresist"] = {
"Bookwyrm", -- has debuffduration for some reason
"Slam Fire", -- has debuffduration for some reason
"Splatter", -- has SlowPercent but no duration to shave off.
},
}
-- Unit strings appended to cell values by column name.
local column_suffix = {
["Time Between Charges"] = "s",
["Duration"] = "s",
}
-- Actual code below
local p = {}
local GameData = require("Module:GameData")
-- ============================================================
-- Shared helpers
-- ============================================================
local function num_val(v)
if type(v) == "number" then return v end
if type(v) == "table" then return v["Value"] end
return nil
end
-- Searches Upgrades[] for a field and returns { tier = N, value = V } at its
-- first appearance, or nil if the field is absent from all upgrade tiers.
local function find_in_upgrades(ability, field)
local upgrades = ability["Upgrades"]
if type(upgrades) ~= "table" then return nil end
for i, tier in ipairs(upgrades) do
if type(tier) == "table" and tier[field] ~= nil then
return { tier = i, value = num_val(tier[field]) }
end
end
return nil
end
-- ============================================================
-- Per-stat cell renderers
-- Each function receives a guaranteed-matching ability record
-- and returns { ["Column Name"] = value }.
-- Human notes are appended by the framework, not here.
-- ============================================================
local function charges_cells(ability)
local base_charges = num_val(ability["AbilityCharges"])
local base_cooldown = ability["AbilityCooldownBetweenCharge"]
local has_base = base_charges ~= nil and base_charges > 0
local upgrade_info = find_in_upgrades(ability, "AbilityCharges")
local cooldown, upgrade_cooldown
if has_base then
if type(base_cooldown) == "number" and base_cooldown > 0 then
cooldown = base_cooldown
else
local cd = find_in_upgrades(ability, "AbilityCooldownBetweenCharge")
if cd and cd.value and cd.value > 0 then cooldown = cd.value end
end
else
local cd = find_in_upgrades(ability, "AbilityCooldownBetweenCharge")
if cd and cd.value and cd.value > 0 then upgrade_cooldown = cd.value end
end
local cells = {}
if has_base then
cells["Charges"] = tostring(base_charges)
if cooldown then
cells["Time Between Charges"] = tostring(cooldown)
end
end
local note_parts = {}
if upgrade_info then
local n = upgrade_info.value or "?"
local charge_str = n .. " charge" .. (n ~= 1 and "s" or "")
if has_base then
table.insert(note_parts, "+" .. charge_str .. " on T" .. upgrade_info.tier .. " upgrade.")
else
local cd_str = upgrade_cooldown and (upgrade_cooldown .. "s") or "unknown"
table.insert(note_parts, "Becomes charged on T" .. upgrade_info.tier
.. " upgrade with " .. charge_str
.. " and " .. cd_str .. " time between charges.")
end
end
if ability["AbilityChargesConditionally"] ~= nil then
table.insert(note_parts, "Has a conditional charge.")
end
if #note_parts > 0 then
cells["Notes"] = table.concat(note_parts, " ")
end
return cells
end
local function find_base_melee_scale(ability)
for _, v in pairs(ability) do
if type(v) == "table" then
local scale = v["Scale"]
if type(scale) == "table"
and scale["Type"] == "melee"
and type(scale["Value"]) == "number"
and scale["Value"] > 0
then
return scale["Value"]
end
end
end
return nil
end
local function build_melee_upgrade_notes(ability, base_melee_scale)
local upgrades = ability["Upgrades"]
if type(upgrades) ~= "table" then return {} end
local notes = {}
for i, tier in ipairs(upgrades) do
if type(tier) == "table" then
for _, v in pairs(tier) do
if type(v) == "table" then
local scale = v["Scale"]
if type(scale) == "table" and type(scale["Value"]) == "number" then
local stype = scale["Type"]
local delta = scale["Value"]
if stype == "heavy_melee" and delta > 0 then
table.insert(notes, "T" .. i .. " upgrade changes scaling to ×"
.. delta .. " of Heavy Melee damage.")
elseif stype == "melee" and delta ~= 0 and base_melee_scale then
local final = math.floor((base_melee_scale + delta) * 1000 + 0.5) / 1000
table.insert(notes, "T" .. i .. " upgrade adds ×"
.. delta .. " scaling (total ×" .. final .. ").")
end
end
end
end
end
end
return notes
end
local function melee_cells(ability)
local cells = {}
local base_scale = find_base_melee_scale(ability)
if base_scale then cells["Light Melee Scaling"] = "×" .. base_scale end
local upgrade_notes = build_melee_upgrade_notes(ability, base_scale)
if #upgrade_notes > 0 then cells["Notes"] = table.concat(upgrade_notes, " ") end
return cells
end
local function find_base_duration(ability)
for _, field in ipairs({ "VenomDuration", "DebuffDuration", "AbilityDuration", "BurnDuration" }) do
local v = num_val(ability[field])
if v and v > 0 then return v, field end
end
return nil, nil
end
local function healreduce_cells(ability)
local cells = {}
local notes = {}
local base_dur, dur_field = find_base_duration(ability)
local first_pct, first_tier, first_is_disable = nil, nil, false
local base_receive = num_val(ability["HealAmpReceivePenaltyPercent"])
local base_regen = num_val(ability["HealAmpRegenPenaltyPercent"])
local base_pct_raw = base_receive or base_regen
if base_pct_raw and base_pct_raw ~= 0 then
first_pct = math.abs(base_pct_raw)
elseif ability["DisableHealing"] and ability["DisableHealing"] ~= 0 then
first_is_disable = true
else
local upgrades = ability["Upgrades"]
if type(upgrades) == "table" then
for i, tier in ipairs(upgrades) do
if type(tier) == "table" then
local up_raw = num_val(tier["HealAmpReceivePenaltyPercent"])
or num_val(tier["HealAmpRegenPenaltyPercent"])
if up_raw and up_raw ~= 0 then
first_pct, first_tier = math.abs(up_raw), i
break
elseif tier["DisableHealing"] and tier["DisableHealing"] ~= 0 then
first_is_disable, first_tier = true, i
break
end
end
end
end
end
if first_is_disable then
cells["Heal Reduction"] = "100%" .. (first_tier and " (T" .. first_tier .. ")" or "")
elseif first_pct then
cells["Heal Reduction"] = tostring(first_pct) .. "%" .. (first_tier and " (T" .. first_tier .. ")" or "")
end
if base_dur then cells["Duration"] = tostring(base_dur) end
local upgrades = ability["Upgrades"]
if type(upgrades) == "table" then
local running_pct, running_dur = first_pct, base_dur
local first_pct_seen = (first_tier == nil)
for i, tier in ipairs(upgrades) do
if type(tier) == "table" then
local tier_notes = {}
local pct_delta = num_val(tier["HealAmpReceivePenaltyPercent"])
or num_val(tier["HealAmpRegenPenaltyPercent"])
if first_tier == i then
first_pct_seen = true
if first_pct then running_pct = first_pct end
elseif first_pct_seen and pct_delta and pct_delta ~= 0 then
local delta_abs = math.abs(pct_delta)
local total = (running_pct or 0) + delta_abs
table.insert(tier_notes, "T" .. i .. " upgrade increases heal reduction by "
.. delta_abs .. "% (total " .. total .. "%).")
running_pct = total
end
if dur_field then
local dur_delta = num_val(tier[dur_field])
if dur_delta and dur_delta ~= 0 and running_dur then
local total_dur = running_dur + dur_delta
table.insert(tier_notes, "T" .. i .. " upgrade "
.. (dur_delta > 0 and "increases" or "decreases")
.. " duration by " .. math.abs(dur_delta)
.. "s (total " .. total_dur .. "s).")
running_dur = total_dur
end
end
for _, n in ipairs(tier_notes) do table.insert(notes, n) end
end
end
end
if #notes > 0 then cells["Notes"] = table.concat(notes, " ") end
return cells
end
local extra_columns = {
["charges"] = {
headers = { "Charges", "Time Between Charges", "Notes" },
get_cells = charges_cells,
},
["melee"] = {
headers = { "Light Melee Scaling", "Notes" },
get_cells = melee_cells,
},
["healreduce"] = {
headers = { "Heal Reduction", "Duration", "Notes" },
get_cells = healreduce_cells,
},
}
-- ============================================================
-- Hero lookup
-- _key_lookup : ability key -> { heroName, abilityNum }
-- _name_to_key : ability name -> ability key
-- Both built together from HeroData + AbilityCards.
-- ============================================================
local _key_lookup = nil
local _name_to_key = nil
local function build_lookup()
if _key_lookup then return _key_lookup, _name_to_key end
_key_lookup = {}
_name_to_key = {}
local hero_data = mw.loadJsonData("Data:HeroData.json")
local active_heroes = {}
for _, hero in pairs(hero_data) do
if type(hero) == "table"
and hero["IsDisabled"] == false
and type(hero["Name"]) == "string"
and (hero["IsSelectable"] == nil or hero["IsSelectable"] ~= false)
then
active_heroes[hero["Name"]] = true
end
end
local cards = mw.loadJsonData("Data:AbilityCards.json")
for _, hero_entry in pairs(cards) do
if type(hero_entry) == "table" then
local hero_name = hero_entry["Name"]
if hero_name and active_heroes[hero_name] then
for slot_str, ability in pairs(hero_entry) do
local slot_num = tonumber(slot_str)
if slot_num and type(ability) == "table" then
local info = { heroName = hero_name, abilityNum = slot_num }
if ability["Key"] then
_key_lookup[ability["Key"]] = info
end
if ability["Name"] and ability["Key"] then
_name_to_key[ability["Name"]] = ability["Key"]
end
end
end
end
end
end
return _key_lookup, _name_to_key
end
-- ============================================================
-- Entry point
-- ============================================================
function p.render(frame)
local stat = mw.text.trim(frame.args[1] or ""):lower()
local internal_keys = stat_to_props[stat]
if not internal_keys then
return '<span class="error">AbilityTable: unknown stat "' .. stat .. '".</span>'
end
local hide_set = {}
local hide_param = mw.text.trim(frame.args["hide"] or "")
if hide_param ~= "" then
for col in hide_param:gmatch("[^,]+") do hide_set[mw.text.trim(col)] = true end
end
local compact = mw.text.trim(frame.args["compact"] or "") ~= ""
local key_lookup, _ = build_lookup()
local spec = extra_columns[stat]
local headers = spec and spec.headers or {}
local get_cells = spec and spec.get_cells or function() return {} end
local stat_notes = human_notes[stat]
-- Build excluded-name set for this stat
local excluded = {}
if exclude_list[stat] then
for _, name in ipairs(exclude_list[stat]) do
excluded[name] = true
end
end
local abilities = GameData.get_entities(GameData.Dataset.ABILITIES, internal_keys)
-- Keep only abilities belonging to active, released heroes and not excluded
local filtered = {}
for _, ability in ipairs(abilities) do
local key = ability["Key"]
if key and key_lookup[key] and not excluded[ability["Name"]] then
table.insert(filtered, ability)
end
end
if #filtered == 0 then
return "No abilities found for stat: " .. stat
end
-- Sort by hero name, then ability slot number.
table.sort(filtered, function(a, b)
local ia = key_lookup[a["Key"]]
local ib = key_lookup[b["Key"]]
if not ia and not ib then return (a["Name"] or "") < (b["Name"] or "") end
if not ia then return false end
if not ib then return true end
if ia.heroName ~= ib.heroName then return ia.heroName < ib.heroName end
return ia.abilityNum < ib.abilityNum
end)
-- Disclaimer
local edit_url = mw.uri.fullUrl("Module:AbilityTable", { action = "edit" })
local disclaimer = frame:preprocess(
'<div class="ability-table-disclaimer">'
.. "This is an automatic list. "
.. "[" .. tostring(edit_url) .. " Click here]"
.. " to add notes or correct the contents."
.. "</div>"
)
if compact then
-- Compact mode: <ul> with one <li> per hero.
-- Each item: "{HeroIcon}: {AbilityIcon} (note), {AbilityIcon}, ..."
-- Group abilities by hero, preserving sort order.
local hero_order = {}
local hero_abilities = {} -- heroName -> list of ability names
for _, ability in ipairs(filtered) do
local info = key_lookup[ability["Key"]]
local hero = info and info.heroName or "—"
if not hero_abilities[hero] then
table.insert(hero_order, hero)
hero_abilities[hero] = {}
end
table.insert(hero_abilities[hero], ability["Name"])
end
local items = {}
for _, hero in ipairs(hero_order) do
local hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { hero } }
local ability_parts = {}
for _, name in ipairs(hero_abilities[hero]) do
local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { name } }
local note = stat_notes and stat_notes[name]
if note then
table.insert(ability_parts, ability_cell .. " (" .. note .. ")")
else
table.insert(ability_parts, ability_cell)
end
end
table.insert(items, "<li>" .. hero_cell .. ": " .. table.concat(ability_parts, ", ") .. "</li>")
end
return disclaimer .. "<ul>" .. table.concat(items) .. "</ul>"
end
-- Normal mode: wikitable with one row per ability and stat-specific columns.
local has_notes_header = false
local final_headers = {}
for _, h in ipairs(headers) do
if h == "Notes" then
has_notes_header = true
elseif not hide_set[h] then
table.insert(final_headers, h)
end
end
local needs_notes = (has_notes_header or stat_notes ~= nil) and not hide_set["Notes"]
if needs_notes then table.insert(final_headers, "Notes") end
local out = {}
table.insert(out, disclaimer)
table.insert(out, '{| class="wikitable sortable ability-table"')
local header_row = '! class="ability-table-col-hero" | Hero'
.. ' !! class="ability-table-col-ability" | Ability'
for _, h in ipairs(final_headers) do
header_row = header_row .. ' !! class="ability-table-col-'
.. h:lower():gsub("%s+", "-") .. '" | ' .. h
end
table.insert(out, header_row)
for _, ability in ipairs(filtered) do
local info = key_lookup[ability["Key"]]
local hero_cell
if info then
hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { info.heroName } }
else
hero_cell = "—"
end
local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { ability["Name"] } }
local extra = get_cells(ability)
if stat_notes and stat_notes[ability["Name"]] then
local human = stat_notes[ability["Name"]]
extra["Notes"] = extra["Notes"] and (extra["Notes"] .. " " .. human) or human
end
local row = '| class="ability-table-cell-hero" | ' .. hero_cell
.. ' || class="ability-table-cell-ability" | ' .. ability_cell
for _, header in ipairs(final_headers) do
local value = extra[header]
local cls = "ability-table-cell-" .. header:lower():gsub("%s+", "-")
local content
if value ~= nil then
local suffix = column_suffix[header] or ""
content = (type(value) == "string" and value:sub(1,1) == "(")
and value or (value .. suffix)
else
content = "—"
end
row = row .. ' || class="' .. cls .. '" | ' .. content
end
table.insert(out, "|-")
table.insert(out, row)
end
table.insert(out, "|}")
return table.concat(out, "\n")
end
return p