Module:AbilityTable/ComplexRenderers

From The Deadlock Wiki
Revision as of 00:17, 14 September 2026 by ~2026-DurationAmmoMelee1155 (talk) (RenderSpiritScaling: list every scaling prop with card labels and {{Ss}}, per-prop upgrade notes (with help from vergir-bot LLM))
Jump to navigation Jump to search

Documentation for this module may be created at Module:AbilityTable/ComplexRenderers/doc

-- Complex cell renderers for AbilityTable.
local Lists = require("Module:AbilityTable/Lists")

-- ============================================================
-- Shared helpers
-- ============================================================

-- Unwraps a value that may be a raw number or a { Value = N } table.
local function unwrap_value(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 = unwrap_value(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), absolute value.
local function find_first_occurrence(ability, fields)
    for _, field in ipairs(fields) do
        local v = unwrap_value(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 = unwrap_value(tier[field])
                    if v and v ~= 0 then return field, i, math.abs(v) end
                end
            end
        end
    end
    return nil, nil, nil
end

-- Sums the non-zero values of `fields` within a single record layer (the base
-- ability record, or one upgrade tier). Returns nil when none are present.
local function sum_fields(layer, fields)
    local total = nil
    for _, field in ipairs(fields) do
        local v = unwrap_value(layer[field])
        if v and v ~= 0 then total = (total or 0) + math.abs(v) end
    end
    return total
end

-- Sums the barrier value and Spirit scaling contributed by a single record
-- layer (the base ability record, or one upgrade tier). Returns nil when the
-- layer grants no barrier at all; a tier that only raises the scaling still
-- counts, so "+0 value, +1.0 scaling" upgrades are not lost.
local function sum_barrier(layer, fields)
    local value, scale, found = 0, 0, false
    for _, field in ipairs(fields) do
        local entry = layer[field]
        if entry ~= nil then
            local v = unwrap_value(entry) or 0
            local s = 0
            if type(entry) == "table" and type(entry["Scale"]) == "table" then
                s = entry["Scale"]["Value"] or 0
            end
            if v ~= 0 or s ~= 0 then found = true end
            value = value + v
            scale = scale + s
        end
    end
    if not found then return nil end
    return value, scale
end

-- Pads a whole-number scaling out to one decimal, so a running total of 2
-- reads "2.0" next to its "1.5" neighbours instead of "2". Values that already
-- carry decimals are left alone, which keeps precision on things like 0.75.
local function format_scale(scale)
    local text = tostring(scale)
    if not text:find(".", 1, true) then text = text .. ".0" end
    return text
end

-- Formats one barrier step. Spirit scaling goes through the compact {{Ss}}
-- form, expanded via the frame the surrounding #invoke is already running
-- under: module return values are not re-expanded, so a literal "{{Ss|0.8}}"
-- would reach the page as text. The plain fallback covers a nil frame
-- (module console).
local function format_barrier(value, scale)
    local out = tostring(value)
    if scale and scale ~= 0 then
        local scale_text = format_scale(scale)
        local frame = mw.getCurrentFrame()
        if frame then
            out = out .. " " .. frame:expandTemplate{
                title = "Ss",
                args  = { scale_text, compact = "1", show_value = "1" },
            }
        else
            out = out .. " ×" .. scale_text
        end
    end
    return out
end

-- Formats a 0–1 multiplier as a percentage: 0.2 → "20%", 0.125 → "12.5%".
-- %.10g absorbs floating-point noise from the ×100.
local function format_percent(v)
    return string.format("%.10g", v * 100) .. "%"
end

-- ============================================================
-- Generic renderer for a single numeric stat that can increase
-- additively through upgrades. Shows value in a named column
-- and generates upgrade notes with running totals.
-- Note: auto-generated notes omit trailing periods; the main
-- module's ensure_period handles that.
-- ============================================================

local function render_upgradable_stat(ability, col_name, fields, suffix)
    suffix = suffix or ""
    local cells = {}
    local notes = {}

    local first_field, first_tier, first_val = find_first_occurrence(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 = unwrap_value(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

-- ============================================================
-- Exported renderers
-- ============================================================

local p = {}

-- Renders the barrier an ability grants, with its Spirit Power scaling.
-- Both the value and the scaling accumulate across upgrades, so every step
-- after the first shows the running total: "100 x0.8 → 180 x1.5 (T2)".
-- A tier that only improves the scaling still gets its own segment.
function p.RenderBarrier(ability)
    local fields   = Lists.lists["barrier"]
    local segments = {}

    local value, scale = sum_barrier(ability, fields)
    if value then
        table.insert(segments, format_barrier(value, scale))
    else
        value, scale = 0, 0
    end

    local upgrades = ability["Upgrades"]
    if type(upgrades) == "table" then
        for i, tier in ipairs(upgrades) do
            if type(tier) == "table" then
                local delta_value, delta_scale = sum_barrier(tier, fields)
                if delta_value then
                    value, scale = value + delta_value, scale + delta_scale
                    table.insert(segments,
                        format_barrier(value, scale) .. " (T" .. i .. ")")
                end
            end
        end
    end

    if #segments == 0 then return {} end
    return { ["Barrier"] = table.concat(segments, " → ") }
end

-- Renders charge count and time between charges.
-- Special case for abilities that gain charges only through upgrades.
function p.RenderCharges(ability)
    local base_charges  = unwrap_value(ability["AbilityCharges"])
    local base_cooldown = unwrap_value(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) .. "s"
        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

-- Renders base damage and melee scaling.
-- Special case for upgrades that switch from Light to Heavy Melee scaling.
function p.RenderMelee(ability)
    local melee_types = {}
    for _, t in ipairs(Lists.lists["melee"]) do melee_types[t] = true end

    -- Find the base property with active melee/heavy_melee scaling (Scale.Value > 0)
    local base_prop, base_damage, base_scale, base_type
    for k, v in pairs(ability) do
        if k ~= "Upgrades" and type(v) == "table" then
            local scale = v["Scale"]
            if type(scale) == "table" and melee_types[scale["Type"]]
               and type(scale["Value"]) == "number" and scale["Value"] > 0 then
                base_prop = k
                base_damage = v["Value"] or 0
                base_scale = scale["Value"]
                base_type = scale["Type"]
                break
            end
        end
    end

    local cells = {}
    cells["Base Damage"] = tostring(base_damage or 0)
    if base_scale then
        cells["Light Melee Scaling"] = "×" .. base_scale
    end

    local upgrades = ability["Upgrades"]
    if type(upgrades) ~= "table" then return cells end

    local notes = {}
    local running_damage = base_damage or 0
    local running_scale = base_scale or 0
    local running_type = base_type

    for i, tier in ipairs(upgrades) do
        if type(tier) == "table" then
            local damage_delta = 0
            local new_scale = nil
            local new_type = nil

            for k, v in pairs(tier) do
                if type(v) == "table" then
                    local scale = v["Scale"]
                    if type(scale) == "table" and melee_types[scale["Type"]] then
                        damage_delta = damage_delta + (v["Value"] or 0)
                        if scale["Value"] > 0 then
                            new_scale = scale["Value"]
                            new_type = scale["Type"]
                        end
                    end
                end
            end

            local flat_delta = 0
            if base_prop and type(tier[base_prop]) == "number" then
                flat_delta = tier[base_prop]
            end

            local tier_parts = {}

            if new_type and new_type ~= running_type then
                running_damage = running_damage + damage_delta
                running_scale = new_scale
                running_type = new_type
                local type_label = new_type == "heavy_melee" and "'''Heavy Melee'''" or "Light Melee"
                local parts = {}
                if running_damage > 0 then
                    table.insert(parts, running_damage .. " base damage")
                end
                table.insert(parts, "×" .. new_scale .. " " .. type_label .. " scaling")
                table.insert(tier_parts, "on T" .. i .. " becomes " .. table.concat(parts, " with "))
            elseif new_scale then
                running_damage = running_damage + damage_delta
                running_scale = running_scale + new_scale
                local parts = {}
                if damage_delta ~= 0 then
                    table.insert(parts, "+" .. damage_delta .. " base damage")
                end
                table.insert(parts, "+×" .. new_scale .. " scaling (total ×" .. running_scale .. ")")
                table.insert(tier_parts, "on T" .. i .. " gets " .. table.concat(parts, ", "))
            end

            if flat_delta ~= 0 and not new_scale then
                running_damage = running_damage + flat_delta
                table.insert(tier_parts, "on T" .. i .. " gets +" .. flat_delta .. " base damage (total " .. running_damage .. ")")
            end

            if #tier_parts > 0 then
                table.insert(notes, table.concat(tier_parts, ", "))
            end
        end
    end

    if #notes > 0 then cells["Notes"] = table.concat(notes, ". ") end
    return cells
end

-- Renders base damage and heavy melee scaling.
-- Notes when heavy melee is only available via upgrade.
function p.RenderHeavyMelee(ability)
    -- Find base heavy_melee property
    local base_damage, base_scale
    for k, v in pairs(ability) do
        if k ~= "Upgrades" and type(v) == "table" then
            local scale = v["Scale"]
            if type(scale) == "table" and scale["Type"] == "heavy_melee"
               and type(scale["Value"]) == "number" then
                base_damage = v["Value"] or 0
                base_scale = scale["Value"]
                break
            end
        end
    end

    local cells = {}

    -- Active at base
    if base_scale and base_scale > 0 then
        cells["Base Damage"] = tostring(base_damage)
        cells["Heavy Melee Scaling"] = "×" .. base_scale
        return cells
    end

    -- Not at base — find in upgrades
    local upgrades = ability["Upgrades"]
    if type(upgrades) ~= "table" then return cells end

    for i, tier in ipairs(upgrades) do
        if type(tier) == "table" then
            for k, v in pairs(tier) do
                if type(v) == "table" then
                    local scale = v["Scale"]
                    if type(scale) == "table" and scale["Type"] == "heavy_melee"
                       and type(scale["Value"]) == "number" and scale["Value"] > 0 then
                        cells["Base Damage"] = tostring(v["Value"] or 0)
                        cells["Heavy Melee Scaling"] = "×" .. scale["Value"]
                        cells["Notes"] = "Only becomes Heavy Melee on '''T" .. i .. "''' upgrade"
                        return cells
                    end
                end
            end
        end
    end

    return cells
end

-- ============================================================
-- Spirit scaling helpers
-- ============================================================

-- Spirit scaling entry of a prop, or nil. Scale is a single { Value, Type }
-- or an array of them (e.g. a duration scaling with both Spirit and duration).
local function spirit_scale(v)
    if type(v) ~= "table" then return nil end
    local scale = v["Scale"]
    if type(scale) ~= "table" then return nil end
    if scale[1] == nil then
        if scale["Type"] == "spirit" then return scale end
        return nil
    end
    for _, entry in ipairs(scale) do
        if type(entry) == "table" and entry["Type"] == "spirit" then return entry end
    end
    return nil
end

-- Non-zero Spirit scalings of one layer (base record or upgrade tier) as
-- { key, value, multiply }, in card order; props not on the card last, by key.
local function spirit_scalings(layer, labels)
    local out = {}
    for k, v in pairs(layer) do
        if k ~= "Upgrades" and tostring(k):sub(1, 1) ~= "_" then
            local s = spirit_scale(v)
            if s and type(s["Value"]) == "number" and s["Value"] ~= 0 then
                table.insert(out, {
                    key      = k,
                    value    = s["Value"],
                    multiply = s["Multiply"] == true,
                })
            end
        end
    end
    table.sort(out, function(a, b)
        local oa = labels[a.key] and labels[a.key].order or math.huge
        local ob = labels[b.key] and labels[b.key].order or math.huge
        if oa ~= ob then return oa < ob end
        return a.key < b.key
    end)
    return out
end

-- Formats a scaling value to 3 significant figures, as the ability cards do.
local function format_spirit(v)
    return string.format("%.3g", v)
end

-- Compact {{Ss}} badge for the cell, expanded via the current frame like
-- format_barrier; plain "×N" when there is no frame (module console).
local function format_spirit_badge(v)
    local text  = format_spirit(v)
    local frame = mw.getCurrentFrame()
    if frame then
        return frame:expandTemplate{
            title = "Ss",
            args  = { text, compact = "1", show_value = "1" },
        }
    end
    return "×" .. text
end

-- "A", "A and B", "A, B and C".
local function join_names(names)
    if #names <= 1 then return names[1] or "" end
    return table.concat(names, ", ", 1, #names - 1) .. " and " .. names[#names]
end

-- Renders Spirit Power scaling: one {{Ss}} badge per scaling prop, labelled from
-- the card ("x0.55 (Base Damage)"; "Damage: Cost of Stay" when names collide;
-- raw key when the card lacks it). Notes list upgrades per prop with running
-- totals; Multiply upgrades multiply the current scaling, as the gadget does.
function p.RenderSpiritScaling(ability, cardprops)
    local labels = cardprops or {}

    local base  = spirit_scalings(ability, labels)
    local tiers = {}   -- { tier = i, list = ... } in tier order
    local upgrades = ability["Upgrades"]
    if type(upgrades) == "table" then
        -- ipairs rather than #: mw.loadJsonData tables have no length.
        for i, tier in ipairs(upgrades) do
            if type(tier) == "table" then
                table.insert(tiers, { tier = i, list = spirit_scalings(tier, labels) })
            end
        end
    end

    -- Props sharing a Name get their Title appended so they can be told apart.
    local keys_by_name = {}
    local function note_keys(list)
        for _, prop in ipairs(list) do
            local info = labels[prop.key]
            if info then
                keys_by_name[info.name] = keys_by_name[info.name] or {}
                keys_by_name[info.name][prop.key] = true
            end
        end
    end
    note_keys(base)
    for _, t in ipairs(tiers) do note_keys(t.list) end

    local function label_of(key)
        local info = labels[key]
        if not info then return key end
        local n = 0
        for _ in pairs(keys_by_name[info.name] or {}) do n = n + 1 end
        if n > 1 and info.title then return info.name .. ": " .. info.title end
        return info.name
    end

    local cells   = {}
    local running = {}   -- prop key -> current total scaling

    if #base > 0 then
        local lines = {}
        for _, prop in ipairs(base) do
            table.insert(lines, format_spirit_badge(prop.value) .. " (" .. label_of(prop.key) .. ")")
            running[prop.key] = prop.value
        end
        cells["Spirit Scaling"] = table.concat(lines, "<br>")
    end

    local notes = {}
    for _, t in ipairs(tiers) do
        local i, list = t.tier, t.list
        local groups, group_order = {}, {}   -- Multiply upgrades, grouped by multiplier
        for _, up in ipairs(list) do
            local label   = label_of(up.key)
            local current = running[up.key]
            if up.multiply then
                local group = groups[up.value]
                if not group then
                    group = { labels = {}, totals = {} }
                    groups[up.value] = group
                    table.insert(group_order, up.value)
                end
                table.insert(group.labels, label)
                if current then
                    running[up.key] = current * up.value
                    table.insert(group.totals, "×" .. format_spirit(running[up.key]))
                else
                    table.insert(group.totals, "?")
                end
            elseif current then
                running[up.key] = current + up.value
                table.insert(notes, "T" .. i .. " upgrade adds +×" .. format_spirit(up.value)
                    .. " " .. label .. " scaling (total ×" .. format_spirit(running[up.key]) .. ")")
            else
                running[up.key] = up.value
                table.insert(notes, "T" .. i .. " upgrade grants ×" .. format_spirit(up.value)
                    .. " " .. label .. " scaling")
            end
        end
        for _, m in ipairs(group_order) do
            local group = groups[m]
            table.insert(notes, "T" .. i .. " upgrade multiplies " .. join_names(group.labels)
                .. " scaling by " .. format_spirit(m)
                .. " (total " .. table.concat(group.totals, ", ") .. ")")
        end
    end

    if #notes > 0 then cells["Notes"] = table.concat(notes, ". ") end
    return cells
end

-- Renders heal reduction percentage.
-- Special case for DisableHealing, shown as 100%.
function p.RenderHealReduce(ability)
    -- DisableHealing is always 100%, handle it separately
    local v = unwrap_value(ability["DisableHealing"])
    if v and v ~= 0 then
        return { ["Heal Reduction"] = "100%" }
    end
    local upgrade = find_in_upgrades(ability, "DisableHealing")
    if upgrade then
        return { ["Heal Reduction"] = "100% (T" .. upgrade.tier .. ")" }
    end

    -- Everything else goes through the generic renderer
    local filtered = {}
    for _, prop in ipairs(Lists.lists["healreduce"]) do
        if prop ~= "DisableHealing" then table.insert(filtered, prop) end
    end
    return render_upgradable_stat(ability, "Heal Reduction", filtered, "%")
end

-- Renders move slow percentage.
-- Appends a note if the ability also self-slows the caster.
function p.RenderMoveSlow(ability)
    local cells = render_upgradable_stat(ability, "Move Slow", Lists.lists["moveslow"], "%")

    local channel_slow = unwrap_value(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

-- Renders dash slow percentage.
-- Special case for GroundDashReductionPercent coexisting with another dashslow
-- prop, which means it's a caster self-slow and is noted separately.
function p.RenderDashSlow(ability)
    local stat_props = Lists.lists["dashslow"]
    local props = {}
    for _, pr in ipairs(stat_props) do table.insert(props, pr) end

    local caster_dash_slow = nil
    local base_ground = unwrap_value(ability["GroundDashReductionPercent"])
    if base_ground and base_ground ~= 0 then
        local has_enemy_prop = false
        for _, prop in ipairs(stat_props) do
            if prop ~= "GroundDashReductionPercent" then
                local v = unwrap_value(ability[prop])
                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 _, prop in ipairs(stat_props) do
                            if prop ~= "GroundDashReductionPercent" then
                                local v = unwrap_value(tier[prop])
                                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 _, prop in ipairs(props) do
                if prop ~= "GroundDashReductionPercent" then
                    table.insert(filtered, prop)
                end
            end
            props = filtered
        end
    end

    local cells = render_upgradable_stat(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

-- Renders the amount of Debuff Resist an ability grants.
-- Upgrades stack additively onto the base value, so every step after the first
-- shows the running total: "20% (T3)" when granted only by an upgrade,
-- "10% → 25% (T2)" when a base value is later increased. Chains for any number
-- of steps. No ability currently has both a base and an upgrade value.
function p.RenderExtraDebuffResist(ability)
    local fields   = Lists.lists["extradebuffresist"]
    local segments = {}

    local running = sum_fields(ability, fields)
    if running then
        table.insert(segments, running .. "%")
    end

    local upgrades = ability["Upgrades"]
    if type(upgrades) == "table" then
        for i, tier in ipairs(upgrades) do
            if type(tier) == "table" then
                local delta = sum_fields(tier, fields)
                if delta then
                    running = (running or 0) + delta
                    table.insert(segments, running .. "% (T" .. i .. ")")
                end
            end
        end
    end

    if #segments == 0 then return {} end
    return { ["Debuff Resist"] = table.concat(segments, " → ") }
end

-- Renders how much of an ability's damage applies to objectives (Guardians,
-- Walkers, Patron, Mid-Boss). BossDamageScale is a 0–1 multiplier shown as a
-- percentage: 0.2 → "20%". The field never appears in upgrade tiers, so only
-- the base value is read; sub-ability records are checked as a fallback when
-- the main record lacks it.
function p.RenderBossDamageScale(ability)
    local v = unwrap_value(ability["BossDamageScale"])
    if (v == nil or v == 0) and type(ability["_subabilities"]) == "table" then
        for _, sub in ipairs(ability["_subabilities"]) do
            local sv = unwrap_value(sub["BossDamageScale"])
            if sv and sv ~= 0 then v = sv; break end
        end
    end
    if v == nil or v == 0 then return {} end
    return { ["Damage to Objectives"] = format_percent(v) }
end

return p