Module:AbilityTable: Difference between revisionsGive feedback
Jump to navigation
Jump to search
Fix heal reduction upgrade note to use "increases by X% (total Y%)" style, consistent with duration notes (with help from vergir-bot LLM) |
Add debuff stat with Effects/Duration columns, manual_entries mechanism, prop_to_effect map (with help from vergir-bot LLM) |
||
| Line 7: | Line 7: | ||
["wpndmg"] = { "melee", "heavy_melee", "weapon_damage_increase" }, | ["wpndmg"] = { "melee", "heavy_melee", "weapon_damage_increase" }, | ||
["healreduce"] = { "HealAmpReceivePenaltyPercent", "HealAmpRegenPenaltyPercent", "DisableHealing" }, | ["healreduce"] = { "HealAmpReceivePenaltyPercent", "HealAmpRegenPenaltyPercent", "DisableHealing" }, | ||
["debuff"] = { | |||
"StunDuration", "SleepDuration", "SilenceDuration", "SilenceDebuff", | |||
"PetrifyDuration", "HexDuration", "ImmobilizeDuration", "LiftDuration", | |||
"SlowPercent", "DebuffDuration", "HoldInPlaceDuration", | |||
}, | |||
} | } | ||
| Line 16: | Line 21: | ||
["melee"] = { | ["melee"] = { | ||
["Puddle Punch"] = "Can be parried.", | ["Puddle Punch"] = "Can be parried.", | ||
}, | |||
} | |||
-- Manually specified entries for abilities that have no matching props, | |||
-- or whose effects cannot be inferred from data alone. | |||
-- Each entry: { effects = { "EffectName", ... }, duration = <number or nil> } | |||
-- effects list may include "(TN)" suffix for upgrade-only effects: "Stun (T3)" | |||
-- duration nil renders as "—". | |||
local manual_entries = { | |||
["debuff"] = { | |||
["Combo"] = { | |||
effects = { "Stun" }, | |||
duration = 2.4, | |||
}, | |||
["Sand Blast"] = { | |||
effects = { "Disarm" }, | |||
duration = 2.5, | |||
}, | |||
["Spirit Lasso"] = { | |||
effects = { "Stun" }, | |||
duration = 2.25, | |||
}, | |||
}, | }, | ||
} | } | ||
-- Unit strings appended to cell values by column name. | -- 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 = { | local column_suffix = { | ||
["Time Between Charges"] = "s", | ["Time Between Charges"] = "s", | ||
| Line 132: | Line 161: | ||
-- additive in the engine, so the final scale = base + upgrade delta. | -- additive in the engine, so the final scale = base + upgrade delta. | ||
-- Returns a list of note strings, one per relevant upgrade tier found. | -- Returns a list of note strings, one per relevant upgrade tier found. | ||
local function build_melee_upgrade_notes(ability, base_melee_scale) | local function build_melee_upgrade_notes(ability, base_melee_scale) | ||
local upgrades = ability["Upgrades"] | local upgrades = ability["Upgrades"] | ||
| Line 151: | Line 177: | ||
if stype == "heavy_melee" and delta > 0 then | if stype == "heavy_melee" and delta > 0 then | ||
table.insert(notes, "T" .. i .. " upgrade changes scaling to ×" | table.insert(notes, "T" .. i .. " upgrade changes scaling to ×" | ||
.. delta .. " of Heavy Melee damage.") | .. delta .. " of Heavy Melee damage.") | ||
elseif stype == "melee" and delta ~= 0 and base_melee_scale then | elseif stype == "melee" and delta ~= 0 and base_melee_scale then | ||
local final = base_melee_scale + delta | local final = base_melee_scale + delta | ||
final = math.floor(final * 1000 + 0.5) / 1000 | final = math.floor(final * 1000 + 0.5) / 1000 | ||
table.insert(notes, "T" .. i .. " upgrade adds ×" | table.insert(notes, "T" .. i .. " upgrade adds ×" | ||
| Line 206: | Line 228: | ||
local base_dur, dur_field = find_base_duration(ability) | local base_dur, dur_field = find_base_duration(ability) | ||
local first_pct = nil | |||
local first_tier = nil | |||
local first_pct = nil | |||
local first_tier = nil | |||
local first_is_disable = false | local first_is_disable = false | ||
| Line 222: | Line 242: | ||
first_tier = nil | first_tier = nil | ||
else | else | ||
local upgrades = ability["Upgrades"] | local upgrades = ability["Upgrades"] | ||
if type(upgrades) == "table" then | if type(upgrades) == "table" then | ||
| Line 244: | Line 263: | ||
end | end | ||
if first_is_disable then | if first_is_disable then | ||
local val = "100%" | local val = "100%" | ||
| Line 255: | Line 273: | ||
end | end | ||
if base_dur then | if base_dur then | ||
cells["Duration"] = tostring(base_dur) | cells["Duration"] = tostring(base_dur) | ||
end | 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 = first_pct | ||
local running_dur | local running_dur = base_dur | ||
local first_pct_seen | local first_pct_seen = (first_tier == nil) | ||
for i, tier in ipairs(upgrades) do | for i, tier in ipairs(upgrades) do | ||
| Line 272: | Line 287: | ||
local tier_notes = {} | local tier_notes = {} | ||
local up_receive = num_val(tier["HealAmpReceivePenaltyPercent"]) | |||
local up_receive = num_val(tier["HealAmpReceivePenaltyPercent"]) | local up_regen = num_val(tier["HealAmpRegenPenaltyPercent"]) | ||
local up_regen | local pct_delta = up_receive or up_regen | ||
local pct_delta | |||
local is_this_first = (first_tier == i) | local is_this_first = (first_tier == i) | ||
if is_this_first then | if is_this_first then | ||
first_pct_seen = true | first_pct_seen = true | ||
if first_pct then running_pct = first_pct end | if first_pct then running_pct = first_pct end | ||
elseif first_pct_seen and pct_delta and pct_delta ~= 0 then | elseif first_pct_seen and pct_delta and pct_delta ~= 0 then | ||
local delta_abs = math.abs(pct_delta) | local delta_abs = math.abs(pct_delta) | ||
local total = (running_pct or 0) + delta_abs | local total = (running_pct or 0) + delta_abs | ||
| Line 291: | Line 303: | ||
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]) | ||
| Line 315: | Line 326: | ||
cells["Notes"] = table.concat(notes, " ") | cells["Notes"] = table.concat(notes, " ") | ||
end | end | ||
return cells | |||
end | |||
-- Maps AbilityData property names to human-readable effect names for the debuff table. | |||
-- Properties listed here are the canonical CC props; DebuffDuration is intentionally | |||
-- absent because it renders as "(See notes)" 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", | |||
} | |||
-- Duration fields corresponding to each CC prop (for reading the numeric value). | |||
-- SlowPercent and SilenceDebuff/HoldInPlaceDuration have separate duration fields. | |||
local debuff_prop_duration_field = { | |||
StunDuration = "StunDuration", | |||
SleepDuration = "SleepDuration", | |||
SilenceDuration = "SilenceDuration", | |||
SilenceDebuff = "SilenceDuration", -- boolean flag; duration in SilenceDuration | |||
PetrifyDuration = "PetrifyDuration", | |||
HexDuration = "HexDuration", | |||
ImmobilizeDuration = "ImmobilizeDuration", | |||
LiftDuration = "LiftDuration", | |||
SlowPercent = "SlowDuration", | |||
HoldInPlaceDuration = "HoldInPlaceDuration", | |||
} | |||
local function debuff_cells(ability, stat_manual) | |||
local cells = {} | |||
-- If a manual entry exists for this ability, use it directly. | |||
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 | |||
-- Collect effects from base props and upgrades. | |||
-- effects_seen: effect_name -> { tier = N or nil, dur_field, value } | |||
-- tier nil = present at base; tier N = first appears at upgrade N | |||
local effects_seen = {} -- ordered list of { name, tier } | |||
local effects_set = {} -- name -> true, for dedup | |||
local CC_PROPS = { | |||
"StunDuration", "SleepDuration", "SilenceDuration", "SilenceDebuff", | |||
"PetrifyDuration", "HexDuration", "ImmobilizeDuration", "LiftDuration", | |||
"SlowPercent", "HoldInPlaceDuration", | |||
} | |||
-- Check 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 | |||
-- Check upgrades | |||
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 | |||
-- Check DebuffDuration separately (adds "(See notes)" sentinel if present) | |||
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 | |||
-- Build Effects cell string | |||
local effect_parts = {} | |||
for _, e in ipairs(effects_seen) do | |||
if e.tier then | |||
table.insert(effect_parts, e.name .. " (T" .. e.tier .. ")") | |||
else | |||
table.insert(effect_parts, e.name) | |||
end | |||
end | |||
if #effect_parts > 0 then | |||
cells["Effects"] = table.concat(effect_parts, ", ") | |||
end | |||
-- Build Duration cell. | |||
-- Rule: find all non-DebuffDuration duration fields present (base or upgrade). | |||
-- If exactly one distinct field found -> use its base or first-appearing value. | |||
-- If DebuffDuration present and it's the only duration -> "(See notes)". | |||
-- If multiple distinct fields -> "(See notes)". | |||
-- If none -> nil (renders as "—"). | |||
local dur_fields_found = {} -- field_name -> value | |||
local DUR_FIELDS = { | |||
"StunDuration", "SleepDuration", "SilenceDuration", "PetrifyDuration", | |||
"HexDuration", "ImmobilizeDuration", "LiftDuration", "SlowDuration", | |||
"HoldInPlaceDuration", | |||
} | |||
for _, f in ipairs(DUR_FIELDS) do | |||
local val = num_val(ability[f]) | |||
if val and val > 0 then | |||
dur_fields_found[f] = val | |||
end | |||
end | |||
-- Also check upgrades for any duration field not present at base | |||
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_fields_found[f] then | |||
local val = num_val(tier[f]) | |||
if val and val > 0 then | |||
dur_fields_found[f] = val | |||
end | |||
end | |||
end | |||
end | |||
end | |||
end | |||
local distinct_dur_count = 0 | |||
local sole_dur_val = nil | |||
for _, val in pairs(dur_fields_found) do | |||
distinct_dur_count = distinct_dur_count + 1 | |||
sole_dur_val = val | |||
end | |||
if distinct_dur_count == 1 then | |||
cells["Duration"] = tostring(sole_dur_val) | |||
elseif distinct_dur_count > 1 or has_debuff_dur then | |||
cells["Duration"] = "(See notes)" | |||
end | |||
-- distinct_dur_count == 0 and no debuff_dur -> Duration stays nil -> "—" | |||
return cells | return cells | ||
| Line 331: | Line 503: | ||
headers = { "Heal Reduction", "Duration", "Notes" }, | headers = { "Heal Reduction", "Duration", "Notes" }, | ||
get_cells = healreduce_cells, | get_cells = healreduce_cells, | ||
}, | |||
["debuff"] = { | |||
headers = { "Effects", "Duration" }, | |||
get_cells = debuff_cells, | |||
}, | }, | ||
} | } | ||
| Line 401: | Line 577: | ||
local spec = extra_columns[stat] | local spec = extra_columns[stat] | ||
local headers = spec and spec.headers or {} | local headers = spec and spec.headers or {} | ||
local stat_notes = human_notes[stat] | local stat_notes = human_notes[stat] | ||
local stat_manual = manual_entries[stat] | |||
-- Wrap the renderer to inject manual_entries for stats that need it | |||
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) | local abilities = GameData.get_entities(GameData.Dataset.ABILITIES, internal_keys) | ||
| Line 412: | Line 600: | ||
if key and lookup[key] then | if key and lookup[key] then | ||
table.insert(filtered, ability) | table.insert(filtered, ability) | ||
end | |||
end | |||
-- Add manual entries whose ability key is not already in filtered | |||
if stat_manual then | |||
local filtered_names = {} | |||
for _, ability in ipairs(filtered) do | |||
filtered_names[ability["Name"]] = true | |||
end | |||
for name, _ in pairs(stat_manual) do | |||
if not filtered_names[name] then | |||
-- Find the ability record by name | |||
-- We search all abilities returned by get_entities with an empty key list | |||
-- instead: search the GameData for this name via a synthetic record | |||
-- Since we can't query by name, we inject a minimal synthetic record | |||
-- that debuff_cells will resolve via the manual entry | |||
table.insert(filtered, { Name = name, Key = "__manual__" .. name }) | |||
end | |||
end | end | ||
end | end | ||
| Line 419: | Line 625: | ||
end | end | ||
-- Sort by hero name, then ability slot number | -- Sort by hero name, then ability slot number. | ||
-- Manual entries (key starts with __manual__) go at the end if not in lookup. | |||
table.sort(filtered, function(a, b) | table.sort(filtered, function(a, b) | ||
local ia = lookup[a["Key"]] | local ia = lookup[a["Key"]] | ||
local ib = lookup[b["Key"]] | local ib = 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 | if ia.heroName ~= ib.heroName then | ||
return ia.heroName < ib.heroName | return ia.heroName < ib.heroName | ||
| Line 430: | Line 640: | ||
-- Determine final header list, respecting hide_set. | -- Determine final header list, respecting hide_set. | ||
local has_notes_header = false | local has_notes_header = false | ||
local final_headers = {} | local final_headers = {} | ||
| Line 472: | Line 680: | ||
local info = lookup[ability["Key"]] | local info = lookup[ability["Key"]] | ||
local hero_cell = frame:expandTemplate{ | local hero_cell, ability_cell | ||
if info then | |||
hero_cell = frame:expandTemplate{ | |||
title = "HeroIcon", | |||
args = { info.heroName } | |||
} | |||
ability_cell = frame:expandTemplate{ | |||
title = "AbilityIcon", | |||
args = { ability["Name"] } | |||
} | |||
else | |||
-- Manual entry with no hero lookup: render name only | |||
hero_cell = "—" | |||
ability_cell = frame:expandTemplate{ | |||
title = "AbilityIcon", | |||
args = { ability["Name"] } | |||
} | |||
end | |||
local extra = get_cells(ability) | local extra = get_cells(ability) | ||
| Line 497: | Line 715: | ||
if value ~= nil then | if value ~= nil then | ||
local suffix = column_suffix[header] or "" | local suffix = column_suffix[header] or "" | ||
content = value .. suffix | -- Don't append suffix to already-complete strings like "(See notes)" | ||
if type(value) == "string" and value:sub(1, 1) == "(" then | |||
content = value | |||
else | |||
content = value .. suffix | |||
end | |||
else | else | ||
content = "—" | content = "—" | ||