Module:AbilityTable: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Add melee extra columns with Light Melee Scaling and automated heavy melee upgrade notes (with help from vergir-bot LLM)
Fix melee scaling notes: additive upgrade interpretation for Rend; updated heavy melee note wording with bold (with help from vergir-bot LLM)
Line 110: Line 110:
end
end


-- Finds the base light melee scale value for a melee ability.
-- Finds the base light melee scale value: the first top-level property
-- Searches all top-level properties for the first one whose Scale.Type
-- whose Scale.Type is "melee" and whose scale value is > 0.
-- is "melee" and whose scale value is > 0.
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 129: Line 128:
end
end


-- Finds the first upgrade tier that introduces a heavy_melee scale > 0.
-- Scans all upgrade tiers for melee-scale changes. Upgrade objects are
-- Returns { tier = N, scale = V } or nil.
-- additive in the engine, so the final scale = base + upgrade delta.
local function find_heavy_melee_upgrade(ability)
-- 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 upgrades = ability["Upgrades"]
     local upgrades = ability["Upgrades"]
     if type(upgrades) ~= "table" then return nil end
     if type(upgrades) ~= "table" then return {} end
 
    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 139: Line 145:
                 if type(v) == "table" then
                 if type(v) == "table" then
                     local scale = v["Scale"]
                     local scale = v["Scale"]
                     if type(scale) == "table"
                     if type(scale) == "table" and type(scale["Value"]) == "number" then
                        and scale["Type"] == "heavy_melee"
                         local stype = scale["Type"]
                         and type(scale["Value"]) == "number"
                         local delta = scale["Value"]
                         and scale["Value"] > 0
 
                    then
                        if stype == "heavy_melee" and delta > 0 then
                        return { tier = i, scale = scale["Value"] }
                            -- Introduces heavy melee scaling (additive on top of zeroed melee)
                            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
                            -- Modifies existing melee scaling additively
                            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
                            table.insert(notes, "T" .. i .. " upgrade adds ×"
                                .. delta .. " [[Melee Damage]] scaling (total ×" .. final .. ").")
                        end
                     end
                     end
                 end
                 end
Line 150: Line 167:
         end
         end
     end
     end
     return nil
 
     return notes
end
end


Line 161: Line 179:
     end
     end


     local heavy_upgrade = find_heavy_melee_upgrade(ability)
     local upgrade_notes = build_melee_upgrade_notes(ability, base_scale)
     if heavy_upgrade then
     if #upgrade_notes > 0 then
         cells["Notes"] = "Gains ×" .. heavy_upgrade.scale
         cells["Notes"] = table.concat(upgrade_notes, " ")
            .. " [[Heavy Melee]] scaling on T" .. heavy_upgrade.tier .. " upgrade."
     end
     end



Revision as of 01:51, 11 May 2026

This module exposes two functions.

  • render generates 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 by render and externally by Module:AbilityHints, so the ability-hint icons always stay in sync with these tables.

See also


-- Renders sortable wikitables of abilities filtered by a named stat.

-- Maps lowercase friendly stat names to internal AbilityData property keys or values.
-- Entries are matched either as key names (property exists with non-zero value)
-- or as plain string values anywhere in the record (e.g. Scale.Type = "melee").
local stat_to_props = {
    ["charges"] = { "AbilityCharges" },
    ["melee"]   = { "melee", "heavy_melee", "light_melee" },
}

-- 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.",
    },
}

-- 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

-- ============================================================
-- 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

-- Finds the base light melee scale value: the first top-level property
-- whose Scale.Type is "melee" and whose scale value is > 0.
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

-- Scans all upgrade tiers for melee-scale changes. Upgrade objects are
-- additive in the engine, so the final scale = base + upgrade delta.
-- 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 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
                            -- Introduces heavy melee scaling (additive on top of zeroed melee)
                            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
                            -- Modifies existing melee scaling additively
                            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
                            table.insert(notes, "T" .. i .. " upgrade adds ×"
                                .. delta .. " [[Melee Damage]] 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 extra_columns = {
    ["charges"] = {
        headers   = { "Charges", "Time Between Charges", "Notes" },
        get_cells = charges_cells,
    },
    ["melee"] = {
        headers   = { "Light Melee Scaling", "Notes" },
        get_cells = melee_cells,
    },
}

-- ============================================================
-- Hero lookup (ability key -> { heroName, abilityNum })
-- ============================================================

local _hero_lookup = nil

local function build_hero_lookup()
    if _hero_lookup then return _hero_lookup end
    _hero_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" and ability["Key"] then
                        _hero_lookup[ability["Key"]] = {
                            heroName   = hero_name,
                            abilityNum = slot_num,
                        }
                    end
                end
            end
        end
    end

    return _hero_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

    -- Build set of columns to hide from the "hide" parameter
    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     = build_hero_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 abilities = GameData.get_entities(GameData.Dataset.ABILITIES, internal_keys)

    -- Keep only abilities belonging to active, released heroes
    local filtered = {}
    for _, ability in ipairs(abilities) do
        local key = ability["Key"]
        if key and lookup[key] then
            table.insert(filtered, ability)
        end
    end

    if #filtered == 0 then
        return "No abilities found for stat: " .. stat
    end

    -- Sort by hero name, then ability slot number
    table.sort(filtered, function(a, b)
        local ia = lookup[a["Key"]]
        local ib = lookup[b["Key"]]
        if ia.heroName ~= ib.heroName then
            return ia.heroName < ib.heroName
        end
        return ia.abilityNum < ib.abilityNum
    end)

    -- 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 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>"
    )

    -- Render wikitable
    local out = {}
    table.insert(out, disclaimer)
    table.insert(out, '{| class="wikitable sortable ability-table"')

    local header_cells = '! class="ability-table-col-hero" | Hero'
                      .. ' !! class="ability-table-col-ability" | Ability'
    for _, h in ipairs(final_headers) do
        local cls = "ability-table-col-" .. h:lower():gsub("%s+", "-")
        header_cells = header_cells .. ' !! class="' .. cls .. '" | ' .. h
    end
    table.insert(out, header_cells)

    for _, ability in ipairs(filtered) do
        local info = lookup[ability["Key"]]

        local hero_cell = frame:expandTemplate{
            title = "HeroIcon",
            args  = { info.heroName }
        }
        local ability_cell = frame:expandTemplate{
            title = "AbilityIcon",
            args  = { ability["Name"] }
        }

        local extra = get_cells(ability)

        -- Append human note to any renderer-generated note
        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_cells = '| 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 = value .. suffix
            else
                content = "—"
            end
            row_cells = row_cells .. ' || class="' .. cls .. '" | ' .. content
        end

        table.insert(out, "|-")
        table.insert(out, row_cells)
    end

    table.insert(out, "|}")
    return table.concat(out, "\n")
end

return p