Module:AbilityTable: Difference between revisionsGive feedback
Jump to navigation
Jump to search
Add debuff stat with Effects/Duration columns, manual_entries mechanism, prop_to_effect map (with help from vergir-bot LLM) |
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 | -- 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: | -- Each entry requires: | ||
-- 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 | ||
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 | ||
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 | local final = math.floor((base_melee_scale + delta) * 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 | ||
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 | ||
return cells | return cells | ||
end | end | ||
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 | ||
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 | local first_pct, first_tier, first_is_disable = nil, nil, 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 | first_pct = math.abs(base_pct_raw) | ||
elseif ability["DisableHealing"] and ability["DisableHealing"] ~= 0 then | elseif ability["DisableHealing"] and ability["DisableHealing"] ~= 0 then | ||
first_is_disable = true | first_is_disable = true | ||
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 | local up_raw = num_val(tier["HealAmpReceivePenaltyPercent"]) | ||
or num_val(tier["HealAmpRegenPenaltyPercent"]) | |||
if up_raw and up_raw ~= 0 then | if up_raw and up_raw ~= 0 then | ||
first_pct | first_pct, first_tier = math.abs(up_raw), 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 | ||
break | break | ||
end | end | ||
| Line 264: | Line 239: | ||
if first_is_disable then | if first_is_disable then | ||
cells["Heal Reduction"] = "100%" .. (first_tier and " (T" .. first_tier .. ")" or "") | |||
elseif first_pct then | elseif first_pct then | ||
cells["Heal Reduction"] = tostring(first_pct) .. "%" .. (first_tier and " (T" .. first_tier .. ")" or "") | |||
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 | local running_pct, running_dur = first_pct, 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 | or num_val(tier["HealAmpRegenPenaltyPercent"]) | ||
if first_tier == i 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 | 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 " | ||
.. (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 | |||
end | end | ||
end | end | ||
end | end | ||
if #notes > 0 then | if #notes > 0 then 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. | ||
-- | -- DebuffDuration is absent: it signals "(See notes)" on Duration 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 | -- Duration fields for each CC prop (the field whose numeric value is the duration). | ||
-- | -- Props that are themselves duration fields map to themselves. | ||
local | -- 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", | 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 = {} | ||
-- | -- 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 | ||
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", | |||
} | } | ||
-- | 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 | ||
-- | -- 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 | ||
-- | -- 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 | ||
-- | -- Effects cell | ||
local effect_parts = {} | local effect_parts = {} | ||
for _, e in ipairs(effects_seen) do | for _, e in ipairs(effects_seen) do | ||
table.insert(effect_parts, e.tier and (e.name .. " (T" .. e.tier .. ")") or e.name) | |||
end | end | ||
if #effect_parts > 0 then | if #effect_parts > 0 then | ||
| Line 439: | Line 393: | ||
end | end | ||
-- | -- Duration cell: collect distinct non-DebuffDuration duration fields (base or upgrade) | ||
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 | ||
end | end | ||
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 | 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 | ||
end | end | ||
end | end | ||
| Line 474: | Line 417: | ||
end | end | ||
-- AbilityDuration is only meaningful as a duration source when DebuffAccuracy is the | |||
local | -- 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 | end | ||
if | local count, sole_val = 0, nil | ||
cells["Duration"] = tostring( | for _, val in pairs(dur_found) do count = count + 1; sole_val = val end | ||
elseif | |||
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 | ||
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" | 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 | ||
| Line 550: | Line 506: | ||
end | end | ||
return _hero_lookup | return _hero_lookup, _name_lookup | ||
end | end | ||
| Line 565: | Line 521: | ||
end | end | ||
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 | ||
end | end | ||
local lookup | local lookup, name_lookup = build_hero_lookup() | ||
local spec | local spec = extra_columns[stat] | ||
local headers | 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] | ||
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 | ||
-- | -- Inject manual entries not already covered by prop-matching | ||
if stat_manual then | if stat_manual then | ||
for name, entry in pairs(stat_manual) do | |||
for name, | |||
if not filtered_names[name] then | 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, | |||
table.insert(filtered, { Name = name, Key = "__manual__" . | 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 | ||
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.abilityNum < ib.abilityNum | return ia.abilityNum < ib.abilityNum | ||
end) | end) | ||
-- Determine final | -- 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 | ||
-- Disclaimer | -- Disclaimer | ||
| Line 664: | Line 611: | ||
) | ) | ||
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 | local header_row = '! class="ability-table-col-hero" | Hero' | ||
.. ' !! class="ability-table-col-ability" | Ability' | |||
for _, h in ipairs(final_headers) do | for _, h in ipairs(final_headers) do | ||
header_row = header_row .. ' !! class="ability-table-col-' | |||
.. h:lower():gsub("%s+", "-") .. '" | ' .. h | |||
end | end | ||
table.insert(out, | 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 } } | ||
else | else | ||
hero_cell = "—" | |||
hero_cell | |||
end | end | ||
ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { ability["Name"] } } | |||
local extra = get_cells(ability) | local extra = get_cells(ability) | ||
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 | local row = '| 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] | ||
| Line 715: | Line 649: | ||
if value ~= nil then | if value ~= nil then | ||
local suffix = column_suffix[header] or "" | local suffix = column_suffix[header] or "" | ||
content = (type(value) == "string" and value:sub(1,1) == "(") | |||
and value | |||
or (value .. suffix) | |||
else | else | ||
content = "—" | content = "—" | ||
end | end | ||
row = row .. ' || class="' .. cls .. '" | ' .. content | |||
end | end | ||
table.insert(out, "|-") | table.insert(out, "|-") | ||
table.insert(out, | table.insert(out, row) | ||
end | end | ||
Revision as of 10:36, 13 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" },
["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