Module:AbilityTable: Difference between revisions

Vergir (talk | contribs)
Fix heal reduction upgrade note to use "increases by X% (total Y%)" style, consistent with duration notes (with help from vergir-bot LLM)
Vergir (talk | contribs)
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.
-- Handles two cases:
--  heavy_melee scale > 0 introduced  → ability gains heavy melee scaling
--  melee scale changed (delta ~= 0)  → existing melee scaling is modified
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
                            -- Introduces heavy melee scaling (additive on top of zeroed melee)
                             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
                            -- Modifies existing melee scaling additively
                             local final = base_melee_scale + delta
                             local final = base_melee_scale + delta
                            -- Round to avoid float noise (e.g. 1.2 + 0.4 = 1.5999...)
                             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)


    -- Determine the first heal reduction value and where it comes from.
     local first_pct        = nil
    -- Check base fields first, then scan upgrades in order.
     local first_tier      = nil
     local first_pct        = nil   -- absolute percent value (positive)
     local first_tier      = nil   -- nil = base ability, N = upgrade tier
     local first_is_disable = false
     local first_is_disable = false


Line 222: Line 242:
         first_tier = nil
         first_tier = nil
     else
     else
        -- Scan upgrades for first occurrence
         local upgrades = ability["Upgrades"]
         local upgrades = ability["Upgrades"]
         if type(upgrades) == "table" then
         if type(upgrades) == "table" then
Line 244: Line 263:
     end
     end


    -- Build Heal Reduction cell value
     if first_is_disable then
     if first_is_disable then
         local val = "100%"
         local val = "100%"
Line 255: Line 273:
     end
     end


    -- Duration column
     if base_dur then
     if base_dur then
         cells["Duration"] = tostring(base_dur)
         cells["Duration"] = tostring(base_dur)
     end
     end


    -- Upgrade notes: percent changes AFTER the first value, and duration changes.
    -- We skip the tier that introduced the first value (already shown in the cell).
     local upgrades = ability["Upgrades"]
     local upgrades = ability["Upgrades"]
     if type(upgrades) == "table" then
     if type(upgrades) == "table" then
         local running_pct     = first_pct
         local running_pct   = first_pct
         local running_dur     = base_dur
         local running_dur   = base_dur
         local first_pct_seen = (first_tier == nil) -- if base, already "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 = {}


                -- Heal reduction changes
                 local up_receive   = num_val(tier["HealAmpReceivePenaltyPercent"])
                 local up_receive = num_val(tier["HealAmpReceivePenaltyPercent"])
                 local up_regen     = num_val(tier["HealAmpRegenPenaltyPercent"])
                 local up_regen   = num_val(tier["HealAmpRegenPenaltyPercent"])
                 local pct_delta     = up_receive or up_regen
                 local pct_delta = up_receive or up_regen
                 local is_this_first = (first_tier == i)
                 local is_this_first = (first_tier == i)


                 if is_this_first then
                 if is_this_first then
                    -- This tier introduced the first value; already shown in cell, skip note.
                     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
                    -- Subsequent percent change — "increases by X% (total Y%)"
                     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


                -- Duration changes (additive deltas in the engine)
                 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 get_cells  = spec and spec.get_cells or function() return {} end
     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.
    -- "Notes" is included if declared in headers or if human notes exist,
    -- but only if not hidden. Either way it appears exactly once at the end.
     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
            title = "HeroIcon",
        if info then
            args  = { info.heroName }
            hero_cell = frame:expandTemplate{
        }
                title = "HeroIcon",
         local ability_cell = frame:expandTemplate{
                args  = { info.heroName }
            title = "AbilityIcon",
            }
            args  = { ability["Name"] }
            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 = "—"