Module:AbilityTable: Difference between revisionsGive feedback
Jump to navigation
Jump to search
Add heal stat; refactor pct_stat_cells to val_stat_cells with suffix param; move % suffix out of hardcode (with help from vergir-bot LLM) |
m more props for heal list |
||
| Line 25: | Line 25: | ||
["heal"] = { | ["heal"] = { | ||
"HealingPerSecond", "HealAmount", "HealFixedHealth", "FlatHealthHealing", | "HealingPerSecond", "HealAmount", "HealFixedHealth", "FlatHealthHealing", | ||
"MaxHealthRegen", "ExternalBonusHealthRegen", "BonusHealthRegen", | "MaxHealthRegen", "ExternalBonusHealthRegen", "BonusHealthRegen", "LifeStealPercentOnHit", "LifestealPercentHero", | ||
"MissingHealthPercentHeal", "MissingHealthBurstPct", "RakeHealPerKill", | "MissingHealthPercentHeal", "MissingHealthBurstPct", "RakeHealPerKill", "TechLifestealPercent", | ||
" | "AbilityLifestealPercentHero", "HealthStealPctHero", "BulletLifestealPercent", "TotalHealthRegen", | ||
"DamageHealMult", "HealPctVsHeroes", "HeartHeal", "InfestHeal", "DeathTaxHeal", | "DamageHealMult", "HealPctVsHeroes", "HeartHeal", "InfestHeal", "DeathTaxHeal", | ||
"HealOnKillPct", "HealMaxHealth", "HealMaxHealthPercent", | "HealOnKillPct", "HealMaxHealth", "HealMaxHealthPercent", "RegenIncomingDamagePercent", | ||
}, | }, | ||
} | } | ||
Revision as of 14:04, 16 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 friendly stat names to internal 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"
},
["moveslow"] = { "SlowPercent", "EnemySlowPct", "MoveSlowPercent", "MaxSlowPercent", "MoveSpeedSlowPct",
"MovementSpeedSlow", "MovementSlowPct", "ClubSlowPercent", "MoveSpeedPenaltyPerStack", "MovementSlow" },
["dashslow"] = { "GroundDashReductionPercent", "MaxGroundDashReductionPercent", "MoveSpeedAndDashSlowPct", "EnemyDashSlowPercent" },
["heal"] = {
"HealingPerSecond", "HealAmount", "HealFixedHealth", "FlatHealthHealing",
"MaxHealthRegen", "ExternalBonusHealthRegen", "BonusHealthRegen", "LifeStealPercentOnHit", "LifestealPercentHero",
"MissingHealthPercentHeal", "MissingHealthBurstPct", "RakeHealPerKill", "TechLifestealPercent",
"AbilityLifestealPercentHero", "HealthStealPctHero", "BulletLifestealPercent", "TotalHealthRegen",
"DamageHealMult", "HealPctVsHeroes", "HeartHeal", "InfestHeal", "DeathTaxHeal",
"HealOnKillPct", "HealMaxHealth", "HealMaxHealthPercent", "RegenIncomingDamagePercent",
},
}
-- Editor-authored notes go here.
local human_notes = {
["charges"] = {
["Jar of Dead"] = "Charges by being near when anything dies.",
},
["melee"] = {
["Puddle Punch"] = "Can be parried.",
},
["debuffresist"] = {
["Aura of Suffering"] = "T1 slow, T3 extra damage. The effect is negligible.",
["Lightning Ball"] = "T2 Slow. The effect is negligible.",
["Static Charge"] = "Stun duration. Delay before stun is not affected.",
["Enchanter's Satchel"] = "T3 Slow.",
["Scorn"] = "T3 extra damage from Mo & Krill.",
["Hotel Guest"] = "Only T2 late check-out stun can be reduced.",
["Jar of Dead"] = "T2 slow.",
["Grapple Arm"] = "T1 extra damage from Bebop.",
["Binding Word"] = "Immobilize Duration. Escape time is not affected.",
["Power Slash"] = "T1 slow.",
["Kinetic Pulse"] = "T2 bullet resist reduction and slow.",
["Pain Battery"] = "T1 slow.",
["Power Surge"] = "T3 Spirit Resist reduction.",
["Rain of Arrows"] = "T2 slow.",
["Soul Exchange"] = "T2 silence.",
["Ground Strike"] = "T2 slow.",
["Flog"] = "T2 Fire rate slow.",
},
["moveslow"] = {
["Grapple Arm"] = "Applies a short strong slow when the victim reaches Bebop.",
["Heavy Barrage"] = "Slows McGinnis by unknown amount.",
},
}
-- Type here to exclude ability from a list. If adding please include a comment.
local exclude_list = {
["debuffresist"] = {
"Bookwyrm", -- has debuffduration for some reason
"Itani Lo Sahn", -- has debuffduration but does not get reduced
"Slam Fire", -- has debuffduration for some reason
"Splatter", -- has SlowPercent but no duration to shave off.
},
["moveslow"] = {
"Riposte", -- has a 40% 4s slow somehow? (SlowPercent + SlowDuration)
"Grasping Hands", -- has SlowPercent
},
["dashslow"] = {
"Itani Lo Sahn", -- has GroundDashReductionPercent but also disables stamina.
},
}
-- Unit strings appended to cell values by column name.
local column_suffix = {
["Time Between Charges"] = "s",
}
-- Actual code below
local p = {}
local GameData = require("Module:GameData")
-- ============================================================
-- Shared helpers
-- ============================================================
local function num_val(v)
if type(v) == "number" then return v end
if type(v) == "table" then return v["Value"] end
return nil
end
-- Searches Upgrades[] for a field and returns { tier = N, value = V } at its
-- first appearance, or nil if the field is absent from all upgrade tiers.
local function find_in_upgrades(ability, field)
local upgrades = ability["Upgrades"]
if type(upgrades) ~= "table" then return nil end
for i, tier in ipairs(upgrades) do
if type(tier) == "table" and tier[field] ~= nil then
return { tier = i, value = num_val(tier[field]) }
end
end
return nil
end
-- Finds the first non-zero value for any of the given fields, checking base
-- then upgrades in order. Returns field, tier (nil=base, N=upgrade), abs value.
local function find_first_value(ability, fields)
for _, field in ipairs(fields) do
local v = num_val(ability[field])
if v and v ~= 0 then return field, nil, math.abs(v) end
end
local upgrades = ability["Upgrades"]
if type(upgrades) == "table" then
for i, tier in ipairs(upgrades) do
if type(tier) == "table" then
for _, field in ipairs(fields) do
local v = num_val(tier[field])
if v and v ~= 0 then return field, i, math.abs(v) end
end
end
end
end
return nil, nil, nil
end
-- ============================================================
-- Per-stat cell renderers
-- Each function receives a guaranteed-matching ability record
-- and returns { ["Column Name"] = value }.
-- Human notes are appended by the framework, not here.
-- ============================================================
local function charges_cells(ability)
local base_charges = num_val(ability["AbilityCharges"])
local base_cooldown = ability["AbilityCooldownBetweenCharge"]
local has_base = base_charges ~= nil and base_charges > 0
local upgrade_info = find_in_upgrades(ability, "AbilityCharges")
local cooldown, upgrade_cooldown
if has_base then
if type(base_cooldown) == "number" and base_cooldown > 0 then
cooldown = base_cooldown
else
local cd = find_in_upgrades(ability, "AbilityCooldownBetweenCharge")
if cd and cd.value and cd.value > 0 then cooldown = cd.value end
end
else
local cd = find_in_upgrades(ability, "AbilityCooldownBetweenCharge")
if cd and cd.value and cd.value > 0 then upgrade_cooldown = cd.value end
end
local cells = {}
if has_base then
cells["Charges"] = tostring(base_charges)
if cooldown then
cells["Time Between Charges"] = tostring(cooldown)
end
end
local note_parts = {}
if upgrade_info then
local n = upgrade_info.value or "?"
local charge_str = n .. " charge" .. (n ~= 1 and "s" or "")
if has_base then
table.insert(note_parts, "+" .. charge_str .. " on T" .. upgrade_info.tier .. " upgrade.")
else
local cd_str = upgrade_cooldown and (upgrade_cooldown .. "s") or "unknown"
table.insert(note_parts, "Becomes charged on T" .. upgrade_info.tier
.. " upgrade with " .. charge_str
.. " and " .. cd_str .. " time between charges.")
end
end
if ability["AbilityChargesConditionally"] ~= nil then
table.insert(note_parts, "Has a conditional charge.")
end
if #note_parts > 0 then
cells["Notes"] = table.concat(note_parts, " ")
end
return cells
end
local function find_base_melee_scale(ability)
for _, v in pairs(ability) do
if type(v) == "table" then
local scale = v["Scale"]
if type(scale) == "table"
and scale["Type"] == "melee"
and type(scale["Value"]) == "number"
and scale["Value"] > 0
then
return scale["Value"]
end
end
end
return nil
end
local function build_melee_upgrade_notes(ability, base_melee_scale)
local upgrades = ability["Upgrades"]
if type(upgrades) ~= "table" then return {} end
local notes = {}
for i, tier in ipairs(upgrades) do
if type(tier) == "table" then
for _, v in pairs(tier) do
if type(v) == "table" then
local scale = v["Scale"]
if type(scale) == "table" and type(scale["Value"]) == "number" then
local stype = scale["Type"]
local delta = scale["Value"]
if stype == "heavy_melee" and delta > 0 then
table.insert(notes, "T" .. i .. " upgrade changes scaling to ×"
.. delta .. " of Heavy Melee damage.")
elseif stype == "melee" and delta ~= 0 and base_melee_scale then
local final = math.floor((base_melee_scale + delta) * 1000 + 0.5) / 1000
table.insert(notes, "T" .. i .. " upgrade adds ×"
.. delta .. " scaling (total ×" .. final .. ").")
end
end
end
end
end
end
return notes
end
local function melee_cells(ability)
local cells = {}
local base_scale = find_base_melee_scale(ability)
if base_scale then cells["Light Melee Scaling"] = "×" .. base_scale end
local upgrade_notes = build_melee_upgrade_notes(ability, base_scale)
if #upgrade_notes > 0 then cells["Notes"] = table.concat(upgrade_notes, " ") end
return cells
end
local function healreduce_cells(ability)
local cells = {}
local notes = {}
local props = stat_to_props["healreduce"]
local first_pct, first_tier, first_is_disable = nil, nil, false
for _, prop in ipairs(props) do
local v = num_val(ability[prop])
if v and v ~= 0 then
if prop == "DisableHealing" then
first_is_disable = true
else
first_pct = math.abs(v)
end
break
end
end
if not first_pct and not first_is_disable then
local upgrades = ability["Upgrades"]
if type(upgrades) == "table" then
for i, tier in ipairs(upgrades) do
if type(tier) == "table" then
for _, prop in ipairs(props) do
local v = num_val(tier[prop])
if v and v ~= 0 then
if prop == "DisableHealing" then
first_is_disable, first_tier = true, i
else
first_pct, first_tier = math.abs(v), i
end
break
end
end
end
if first_pct or first_is_disable then break end
end
end
end
if first_is_disable then
cells["Heal Reduction"] = "100%" .. (first_tier and " (T" .. first_tier .. ")" or "")
elseif first_pct then
cells["Heal Reduction"] = tostring(first_pct) .. "%" .. (first_tier and " (T" .. first_tier .. ")" or "")
end
local upgrades = ability["Upgrades"]
if type(upgrades) == "table" then
local running = first_pct
local first_pct_seen = (first_tier == nil)
for i, tier in ipairs(upgrades) do
if type(tier) == "table" then
if first_tier == i then
first_pct_seen = true
if first_pct then running = first_pct end
elseif first_pct_seen then
local delta = nil
for _, prop in ipairs(props) do
if prop ~= "DisableHealing" then
local v = num_val(tier[prop])
if v and v ~= 0 then delta = math.abs(v); break end
end
end
if delta then
local total = (running or 0) + delta
table.insert(notes, "T" .. i .. " upgrade increases heal reduction by "
.. delta .. "% (total " .. total .. "%).")
running = total
end
end
end
end
end
if #notes > 0 then cells["Notes"] = table.concat(notes, " ") end
return cells
end
-- Generic renderer for a numeric stat column.
-- col_name: the column header string
-- fields: ordered list of property names (first non-zero wins)
-- suffix: string appended to values in cell and notes (e.g. "%" or "")
-- Upgrade deltas are additive; notes track running totals.
local function val_stat_cells(ability, col_name, fields, suffix)
suffix = suffix or ""
local cells = {}
local notes = {}
local first_field, first_tier, first_val = find_first_value(ability, fields)
if not first_field then return cells end
local cell_str = tostring(first_val) .. suffix
if first_tier then cell_str = cell_str .. " (T" .. first_tier .. ")" end
cells[col_name] = cell_str
local upgrades = ability["Upgrades"]
if type(upgrades) == "table" then
local running = first_val
local seen_first = (first_tier == nil)
for i, tier in ipairs(upgrades) do
if type(tier) == "table" then
if first_tier == i then
seen_first = true
elseif seen_first then
local delta = nil
for _, field in ipairs(fields) do
local v = num_val(tier[field])
if v and v ~= 0 then delta = math.abs(v); break end
end
if delta then
local total = running + delta
table.insert(notes, "T" .. i .. " upgrade increases "
.. col_name:lower() .. " by " .. delta .. suffix
.. " (total " .. total .. suffix .. ").")
running = total
end
end
end
end
end
if #notes > 0 then cells["Notes"] = table.concat(notes, " ") end
return cells
end
local function moveslow_cells(ability)
local cells = val_stat_cells(ability, "Move Slow", stat_to_props["moveslow"], "%")
local channel_slow = num_val(ability["ChannelSlowPercent"])
if channel_slow and channel_slow ~= 0 then
local note = "Also slows the caster for " .. math.abs(channel_slow) .. "%."
cells["Notes"] = cells["Notes"] and (cells["Notes"] .. " " .. note) or note
end
return cells
end
local function dashslow_cells(ability)
local props = stat_to_props["dashslow"]
local caster_dash_slow = nil
local base_ground = num_val(ability["GroundDashReductionPercent"])
if base_ground and base_ground ~= 0 then
local has_enemy_prop = false
for _, p in ipairs(stat_to_props["dashslow"]) do
if p ~= "GroundDashReductionPercent" then
local v = num_val(ability[p])
if v and v ~= 0 then has_enemy_prop = true; break end
end
end
if not has_enemy_prop then
local upgrades = ability["Upgrades"]
if type(upgrades) == "table" then
for _, tier in ipairs(upgrades) do
if type(tier) == "table" then
for _, p in ipairs(stat_to_props["dashslow"]) do
if p ~= "GroundDashReductionPercent" then
local v = num_val(tier[p])
if v and v ~= 0 then has_enemy_prop = true; break end
end
end
end
if has_enemy_prop then break end
end
end
end
if has_enemy_prop then
caster_dash_slow = math.abs(base_ground)
local filtered = {}
for _, p in ipairs(props) do
if p ~= "GroundDashReductionPercent" then
table.insert(filtered, p)
end
end
props = filtered
end
end
local cells = val_stat_cells(ability, "Dash Slow", props, "%")
if caster_dash_slow then
local note = "Gives " .. caster_dash_slow .. "% dash slow for the caster."
cells["Notes"] = cells["Notes"] and (cells["Notes"] .. " " .. note) or note
end
return cells
end
local function heal_cells(ability)
return val_stat_cells(ability, "Heal Amount", stat_to_props["heal"], "")
end
local extra_columns = {
["charges"] = {
headers = { "Charges", "Time Between Charges", "Notes" },
get_cells = charges_cells,
},
["melee"] = {
headers = { "Light Melee Scaling", "Notes" },
get_cells = melee_cells,
},
["healreduce"] = {
headers = { "Heal Reduction", "Notes" },
get_cells = healreduce_cells,
},
["moveslow"] = {
headers = { "Move Slow", "Notes" },
get_cells = moveslow_cells,
},
["dashslow"] = {
headers = { "Dash Slow", "Notes" },
get_cells = dashslow_cells,
},
["heal"] = {
headers = { "Heal Amount", "Notes" },
get_cells = heal_cells,
},
}
-- ============================================================
-- Hero lookup
-- _key_lookup : ability key -> { heroName, abilityNum }
-- _name_to_key : ability name -> ability key
-- Both built together from HeroData + AbilityCards.
-- ============================================================
local _key_lookup = nil
local _name_to_key = nil
local function build_lookup()
if _key_lookup then return _key_lookup, _name_to_key end
_key_lookup = {}
_name_to_key = {}
local hero_data = mw.loadJsonData("Data:HeroData.json")
local active_heroes = {}
for _, hero in pairs(hero_data) do
if type(hero) == "table"
and hero["IsDisabled"] == false
and type(hero["Name"]) == "string"
and (hero["IsSelectable"] == nil or hero["IsSelectable"] ~= false)
then
active_heroes[hero["Name"]] = true
end
end
local cards = mw.loadJsonData("Data:AbilityCards.json")
for _, hero_entry in pairs(cards) do
if type(hero_entry) == "table" then
local hero_name = hero_entry["Name"]
if hero_name and active_heroes[hero_name] then
for slot_str, ability in pairs(hero_entry) do
local slot_num = tonumber(slot_str)
if slot_num and type(ability) == "table" then
local info = { heroName = hero_name, abilityNum = slot_num }
if ability["Key"] then
_key_lookup[ability["Key"]] = info
end
if ability["Name"] and ability["Key"] then
_name_to_key[ability["Name"]] = ability["Key"]
end
end
end
end
end
end
return _key_lookup, _name_to_key
end
-- ============================================================
-- Entry point
-- ============================================================
function p.render(frame)
local stat = mw.text.trim(frame.args[1] or ""):lower()
local internal_keys = stat_to_props[stat]
if not internal_keys then
return '<span class="error">AbilityTable: unknown stat "' .. stat .. '".</span>'
end
local hide_set = {}
local hide_param = mw.text.trim(frame.args["hide"] or "")
if hide_param ~= "" then
for col in hide_param:gmatch("[^,]+") do hide_set[mw.text.trim(col)] = true end
end
local compact = mw.text.trim(frame.args["compact"] or "") ~= ""
local key_lookup, _ = build_lookup()
local spec = extra_columns[stat]
local headers = spec and spec.headers or {}
local get_cells = spec and spec.get_cells or function() return {} end
local stat_notes = human_notes[stat]
local excluded = {}
if exclude_list[stat] then
for _, name in ipairs(exclude_list[stat]) do
excluded[name] = true
end
end
local abilities = GameData.get_entities(GameData.Dataset.ABILITIES, internal_keys)
local filtered = {}
for _, ability in ipairs(abilities) do
local key = ability["Key"]
if key and key_lookup[key] and not excluded[ability["Name"]] then
table.insert(filtered, ability)
end
end
if #filtered == 0 then
return "No abilities found for stat: " .. stat
end
table.sort(filtered, function(a, b)
local ia = key_lookup[a["Key"]]
local ib = key_lookup[b["Key"]]
if not ia and not ib then return (a["Name"] or "") < (b["Name"] or "") end
if not ia then return false end
if not ib then return true end
if ia.heroName ~= ib.heroName then return ia.heroName < ib.heroName end
return ia.abilityNum < ib.abilityNum
end)
local edit_url = mw.uri.fullUrl("Module:AbilityTable", { action = "edit" })
local disclaimer = frame:preprocess(
'<div class="ability-table-disclaimer">'
.. "This is an automatic list. "
.. "[" .. tostring(edit_url) .. " Click here]"
.. " to add notes or correct the contents."
.. "</div>"
)
if compact then
local hero_order = {}
local hero_abilities = {}
for _, ability in ipairs(filtered) do
local info = key_lookup[ability["Key"]]
local hero = info and info.heroName or "—"
if not hero_abilities[hero] then
table.insert(hero_order, hero)
hero_abilities[hero] = {}
end
table.insert(hero_abilities[hero], ability["Name"])
end
local items = {}
for _, hero in ipairs(hero_order) do
local hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { hero } }
local ability_parts = {}
for _, name in ipairs(hero_abilities[hero]) do
local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { name } }
local note = stat_notes and stat_notes[name]
if note then
table.insert(ability_parts, ability_cell .. " (" .. note .. ")")
else
table.insert(ability_parts, ability_cell)
end
end
table.insert(items, "<li>" .. hero_cell .. ": " .. table.concat(ability_parts, ", ") .. "</li>")
end
return disclaimer .. "<ul>" .. table.concat(items) .. "</ul>"
end
local has_notes_header = false
local final_headers = {}
for _, h in ipairs(headers) do
if h == "Notes" then
has_notes_header = true
elseif not hide_set[h] then
table.insert(final_headers, h)
end
end
local needs_notes = (has_notes_header or stat_notes ~= nil) and not hide_set["Notes"]
if needs_notes then table.insert(final_headers, "Notes") end
local out = {}
table.insert(out, disclaimer)
table.insert(out, '{| class="wikitable sortable ability-table"')
local header_row = '! class="ability-table-col-hero" | Hero'
.. ' !! class="ability-table-col-ability" | Ability'
for _, h in ipairs(final_headers) do
header_row = header_row .. ' !! class="ability-table-col-'
.. h:lower():gsub("%s+", "-") .. '" | ' .. h
end
table.insert(out, header_row)
for _, ability in ipairs(filtered) do
local info = key_lookup[ability["Key"]]
local hero_cell
if info then
hero_cell = frame:expandTemplate{ title = "HeroIcon", args = { info.heroName } }
else
hero_cell = "—"
end
local ability_cell = frame:expandTemplate{ title = "AbilityIcon", args = { ability["Name"] } }
local extra = get_cells(ability)
if stat_notes and stat_notes[ability["Name"]] then
local human = stat_notes[ability["Name"]]
extra["Notes"] = extra["Notes"] and (extra["Notes"] .. " " .. human) or human
end
local row = '| class="ability-table-cell-hero" | ' .. hero_cell
.. ' || class="ability-table-cell-ability" | ' .. ability_cell
for _, header in ipairs(final_headers) do
local value = extra[header]
local cls = "ability-table-cell-" .. header:lower():gsub("%s+", "-")
local content
if value ~= nil then
local suffix = column_suffix[header] or ""
content = (type(value) == "string" and value:sub(1,1) == "(")
and value or (value .. suffix)
else
content = "—"
end
row = row .. ' || class="' .. cls .. '" | ' .. content
end
table.insert(out, "|-")
table.insert(out, row)
end
table.insert(out, "|}")
return table.concat(out, "\n")
end
return p