Module:Sandbox/Vergir: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Vergir (talk | contribs)
Final test draft of StatScaling before publishing (with help from vergir-bot LLM)
Vergir (talk | contribs)
Testing merged alt-fire rows for heroBulletDamageTable (with help from vergir-bot LLM)
Line 1: Line 1:
-- Builds tables of hero base stats that scale with a given scaling type,
local p = {};
-- read from [[Data:HeroData.json]].
local util_module = require('Module:Utilities')
--
local allHeroData = mw.loadJsonData("Data:HeroData.json")
-- Each public function below renders one table. The shared work lives in
local soul_unlock = require('Module:SoulUnlock')
-- build_scaling_table(), so adding a table for another scaling type (Boons,
local inGameHeroData = {}
-- for instance) only takes a new wrapper naming its scaling type.
--
-- {{#invoke:Sandbox/Vergir|write_spirit_scaling_table}}
local p = {}


local heroes_data      = mw.loadJsonData("Data:HeroData.json")
-- Just get heroes that are playable and have a "name" in HeroData
local attributes_data  = mw.loadJsonData("Data:AttributeData.json")
for i, heroData in pairs(allHeroData) do
local util_module      = require('Module:Utilities')
if heroData["InDevelopment"] == false then
local lang_module      = require('Module:Lang')
    if heroData["IsDisabled"] == false then
local dictionary_module = require('Module:Dictionary')
    if heroData["Name"] ~= nil then
local hero_data_module  = require('Module:HeroData')
        table.insert(inGameHeroData, heroData)
        end
        end
    end
end


-- Never listed: both are derived from the weapon stats that are already
-- Use sort name for edge cases like "The Doorman"
-- listed in their own right, so they only ever duplicate a row.
local function sortHeroes(rows)
local EXCLUDED_STATS = {
    local function getSortName(name)
DPS = true,
        return (name:gsub("^The ", ""))
SustainedDPS = true,
    end
}


-- Per stat handling of the base value shown before the scaling.
    table.sort(rows, function(a, b)
--  text:    printed verbatim, for stats the data holds no base value for
        local nameA = getSortName(a.sortName or a.name or "")
--  sig_figs: rounds an over-precise base value
        local nameB = getSortName(b.sortName or b.name or "")
local BASE_DISPLAY = {
        return nameA:lower() < nameB:lower()
BulletResist    = {text = "0%"},
    end)
TechResist      = {text = "0%"},
end
RoundsPerSecond = {sig_figs = 3},
}


--------------------------------------------------------------------------------
-- Helper function to round numeric values
-- Helpers
local function roundValue(value)
--------------------------------------------------------------------------------
    if type(value) == "number" then
        return util_module.round_to_sig_fig(value, 3)
    end
    return value
end


-- next() ignores metamethods and mw.loadJsonData hands back proxy tables, so
-- Round to an integer (up to 2 digits)
-- keys always have to be collected through pairs() rather than tested with next().
local function roundValueInteger(value)
local function sorted_keys(tbl)
    if type(value) == "number" then
local keys = {}
        return util_module.round_to_sig_fig(value, 2)
if (tbl == nil) then return keys end
    end
for key in pairs(tbl) do table.insert(keys, key) end
    return value
table.sort(keys)
return keys
end
end


-- Localized name of a stat, resolved in three steps:
-- Generate tables from given properties
--   1. [[Data:AttributeData.json]] label -> Valve's own translation
local function buildTable(frame, rows, tableDef, options)
--  2. [[Data:Dictionary]] entry, for stats Valve has no label for
    options = options or {}
--  3. the raw key, spaced out, with a missing-translation tooltip
    -- option to ignore heroes who don't have any of the given properties. true by default
local function stat_label(stat)
    local filterZero = options.filterZero or true
for _, category in pairs(attributes_data) do
 
local attribute = category[stat]
    local filteredRows = {}
if (attribute ~= nil and attribute["label"] ~= nil) then
 
local localized = lang_module.get_string(attribute["label"])
    for _, r in ipairs(rows) do
if (localized ~= nil and localized ~= '') then return localized end
        local hasValue = not filterZero
end
 
end
        if filterZero then
            for _, col in ipairs(tableDef.columns) do
                if r[col.field] ~= 0 and r[col.field] ~= nil then
                if r[col.field] ~= "+0" then
                    hasValue = true
                    break
                    end
                end
            end
        end
 
        if hasValue then
            table.insert(filteredRows, r)
        end
    end
 
    if #filteredRows == 0 then
        return ""
    end
 
    -- Build header
    local header = ""
    for _, col in ipairs(tableDef.columns) do
        header = header .. " !! " .. col.label
    end


local translated = dictionary_module.translate(stat)
    -- Start table
if (translated ~= nil and translated:sub(1, 5) ~= "Key '") then return translated end
    local t = "{| class=\"wikitable sortable mw-collapsible\" style=\"text-align:center\" \n" ..
        "|+ " .. tableDef.title .. " \n" ..
        "! Hero" .. header .. " \n"


return util_module.add_space_before_cap(stat) ..
    -- Rows
mw.getCurrentFrame():expandTemplate{title = "MissingValveTranslationTooltip"}
    for _, r in ipairs(filteredRows) do
end
        local cells = ""
        for _, col in ipairs(tableDef.columns) do
            cells = cells .. " || " .. (r[col.field] or 0)
        end


-- Heroes are listed as they are read, so "The Doorman" sorts under D.
        -- Rows may carry an alternate sort key and a label suffix, used for
local function sort_name(hero_data, hero_key)
        -- secondary fire rows listed underneath their hero's main row
return (hero_data["Name"] or hero_key):gsub("^The ", "")
        local nameCell = "| style=\"text-align:left;\""
end
        if r.sortName then
            nameCell = nameCell .. " data-sort-value=\"" .. r.sortName .. "\""
        end
        nameCell = nameCell .. " | " ..
            frame:expandTemplate{ title = "PageRef", args = { r.name } } ..
            (r.nameSuffix or "")


local function hero_icon(hero_key, hero_data)
        t = t ..
return mw.getCurrentFrame():expandTemplate{
            "|- \n" ..
title = "HeroIcon",
            nameCell ..
args = {[1] = hero_data["Name"] or hero_key, l1 = lang_module.get_string(hero_key)},
            cells .. " \n"
}
    end
end


-- Base value as displayed, or nil when the stat has none to show.
    t = t .. "|}"
local function base_value(hero_data, stat)
local display = BASE_DISPLAY[stat]
if (display ~= nil and display.text ~= nil) then return display.text end


local base = hero_data_module.get_stat(hero_data, stat)
    return t
if (type(base) ~= "number") then return nil end
if (display ~= nil and display.sig_figs ~= nil) then
base = util_module.round_to_sig_fig(base, display.sig_figs)
end
return tostring(base)
end
end


-- "6.3 + x0.0084", or the scaling alone when there is no base value to show.
-- heroDataArray takes as argument a table of any length containing keys or
local function scaling_cell(hero_data, stat, value, scaling_type)
-- paths of keys in the HeroData JSON, e.g. {Name, MaxHealth, LevelScaling>Health}.
local scaling_str = hero_data_module.write_scalar_str(value, scaling_type)
-- A path of keys should be specified as such, with the ">" character separating
local base = base_value(hero_data, stat)
-- subsequent keys.
if (base == nil) then return scaling_str end
local heroDataArray = function(args)
return base .. ' + ' .. scaling_str
    local outData = {}
   
    for i, heroData in ipairs(inGameHeroData) do            -- Iterate over each hero
        local out = {}                                      -- First creating a table to hold data
        for j, key in pairs(args) do                        -- Iterate over each key:
            if string.find(key, ">") ~= nil then            -- If the key is actually a path of keys,
                local node = heroData                      -- start with the biggest node heroData, and
                for k in string.gmatch(key, "([^>]+)") do  -- iterate over each key, splitting on ">",
                    if node and node[k] ~= nil then        -- trying the key as a string e.g. "1", (Added 'node and' for safety)
                        node = node[k]                     
                    elseif node and node[tonumber(k)] ~= nil then -- else using the key as numeric, (Added 'node and' for safety)
                        node = node[tonumber(k)]
                    else                                    -- If at any point the path breaks, the result is nil
                        node = nil
                        break
                    end                                    -- drilling down the JSON node after node
                end
                out[key] = roundValue(node)                 -- until outputting the final node (rounded)
            else
                out[key] = roundValue(heroData[key])        -- or just grab the data if not a path (rounded)
            end
        end
        outData[heroData["Name"]] = out                    -- Save data in the big table of heroes
    end
    return(outData)
end
end


-- Every scaling a hero has of one type, minus the excluded stats and any
p.heroBulletDamageTable = function(frame)
-- zeroes, ordered by localized stat name:
    -- Pull data
--  { { stat = , label = , value = }, ... }
    local heroBulletDamageData = heroDataArray({"Name", "Weapon>BulletDamage", "LevelScaling>BulletDamage", "Weapon>AltFire>BulletDamage", "LevelScaling>BulletDamageAltFire",
local function hero_scalings(hero_data, scaling_key)
    "SpiritScaling>BulletDamage", "Weapon>BulletsPerShot", "Weapon>BulletsPerBurst"})
local scalings = {}
local hero_scaling_data = hero_data[scaling_key]
for _, stat in ipairs(sorted_keys(hero_scaling_data)) do
local value = hero_scaling_data[stat]
if (not EXCLUDED_STATS[stat] and type(value) == "number" and value ~= 0) then
table.insert(scalings, {stat = stat, label = stat_label(stat), value = value})
end
end
table.sort(scalings, function(a, b)
if (a.label == b.label) then return a.stat < b.stat end
return a.label < b.label
end)
return scalings
end


--------------------------------------------------------------------------------
    local rows = {}
-- Shared table builder
    for name, data in pairs(heroBulletDamageData) do
--------------------------------------------------------------------------------
        table.insert(rows, {
            name = name,
            bulletDamage = data["Weapon>BulletDamage"] or 0,
            bulletDamagePerShot = ((data["Weapon>BulletDamage"] or 0) * (data["Weapon>BulletsPerShot"] or 0) * (data["Weapon>BulletsPerBurst"] or 0)),
            scalingDamage = "+" .. data["LevelScaling>BulletDamage"] or 0,
            maxScalingDamage =  (roundValue(data["Weapon>BulletDamage"] + data["LevelScaling>BulletDamage"] * soul_unlock.get_max("PowerIncrease"))) or 0,
            altBulletDamage = data["Weapon>AltFire>BulletDamage"] or 0,
            spiritScalingDamage = "+" .. (data["SpiritScaling>BulletDamage"] or 0),
        })


-- scaling_type: "Spirit" or "Level", picking both the <type>Scaling data key
        -- Secondary fire is listed as its own row, right under the hero's main row
-- and the scaling template ({{Ss}} or {{PI}}).
        local altDamage = data["Weapon>AltFire>BulletDamage"]
local function build_scaling_table(scaling_type)
        if altDamage and altDamage ~= 0 then
local scaling_key = scaling_type .. "Scaling"
            local altScaling = data["LevelScaling>BulletDamageAltFire"] or 0
            table.insert(rows, {
                name = name,
                sortName = name .. " (alt fire)",
                nameSuffix = " (alt fire)",
                bulletDamage = altDamage,
                scalingDamage = "+" .. altScaling,
                maxScalingDamage = roundValue(altDamage + altScaling * soul_unlock.get_max("PowerIncrease")),
                spiritScalingDamage = "+0",
            })
        end
    end
    sortHeroes(rows)


-- Collect the heroes that have anything to show
    local tableDefs = {
local rows = {}
    {
for _, hero_key in ipairs(sorted_keys(heroes_data)) do
            title  = "Base Bullet Damage Stats",
local hero_data = heroes_data[hero_key]
            columns = {
if (not hero_data["InDevelopment"] and not hero_data["IsDisabled"]) then
                { label = "Starting", field = "bulletDamage" },
local scalings = hero_scalings(hero_data, scaling_key)
                { label = "Added per Boon", field = "scalingDamage"  },
if (#scalings > 0) then
                { label = "At Max Boon", field = "maxScalingDamage"  },
table.insert(rows, {
                { label = "Spirit Scaling", field = "spiritScalingDamage"  },
key = hero_key,
            }
data = hero_data,
        },
sort_name = sort_name(hero_data, hero_key),
    }
scalings = scalings,
})
end
end
end
if (#rows == 0) then return '' end
table.sort(rows, function(a, b) return a.sort_name < b.sort_name end)


-- Build the table
    local output = {}
local output = {
    for _, def in ipairs(tableDefs) do
'{| class="wikitable sortable"',
        local t = buildTable(frame, rows, def, def.options)
'! ' .. dictionary_module.translate("Hero"),
        if t ~= "" then
'! ' .. dictionary_module.translate("Stats"),
            table.insert(output, t)
'! ' .. dictionary_module.translate("Scaling"),
        end
}
    end
for _, row in ipairs(rows) do
for index, scaling in ipairs(row.scalings) do
table.insert(output, '|-')
if (index == 1) then
local attributes = 'data-sort-value="' .. row.sort_name .. '"'
if (#row.scalings > 1) then
attributes = attributes .. ' rowspan="' .. #row.scalings .. '"'
end
table.insert(output, '| ' .. attributes .. ' | ' .. hero_icon(row.key, row.data))
end
table.insert(output, '| ' .. scaling.label)
table.insert(output, '| ' .. scaling_cell(row.data, scaling.stat, scaling.value, scaling_type))
end
end
table.insert(output, '|}')


return table.concat(output, '\n')
    return table.concat(output, "\n\n")
end
end


--------------------------------------------------------------------------------
-- Kept for regression testing: an untouched table that also goes through
-- Wikitext entry points
-- buildTable() and sortHeroes()
--------------------------------------------------------------------------------
p.heroHealthTable = function(frame)
    -- Pull data
    local heroHealthData = heroDataArray({"Name", "MaxHealth", "LevelScaling>MaxHealth"})
 
    local rows = {}
    for name, data in pairs(heroHealthData) do
        table.insert(rows, {
            name = name,
            maxHealth = data["MaxHealth"] or 0,
            scalingHealth = "+" .. data["LevelScaling>MaxHealth"] or 0,
            maxScalingHealth = roundValue(data["MaxHealth"] + data["LevelScaling>MaxHealth"] * soul_unlock.get_max("PowerIncrease")) or 0
        })
    end
    sortHeroes(rows)
 
    local tableDefs = {
        {
            title = "Base Health Stats",
            columns = {
                { label = "Starting", field = "maxHealth" },
                { label = "Added per Boon", field = "scalingHealth" },
                { label = "At Max Boon", field = "maxScalingHealth" },
            }
        }
    }
 
local output = {}
    for _, def in ipairs(tableDefs) do
        local t = buildTable(frame, rows, def, def.options)
        if t ~= "" then
            table.insert(output, t)
        end
    end


-- Hero base stats that scale with Spirit Power.
    return table.concat(output, "\n\n")
--{{#invoke:Sandbox/Vergir|write_spirit_scaling_table}}
function p.write_spirit_scaling_table(frame)
return build_scaling_table("Spirit")
end
end


return p
return p

Revision as of 21:08, 11 August 2026

Documentation for this module may be created at Module:Sandbox/Vergir/doc

local p = {};
local util_module = require('Module:Utilities')
local allHeroData = mw.loadJsonData("Data:HeroData.json")
local soul_unlock = require('Module:SoulUnlock')
local inGameHeroData = {}

-- Just get heroes that are playable and have a "name" in HeroData
for i, heroData in pairs(allHeroData) do
	if heroData["InDevelopment"] == false then
    if heroData["IsDisabled"] == false then
    if heroData["Name"] ~= nil then
        table.insert(inGameHeroData, heroData)
        end
        end
    end
end

-- Use sort name for edge cases like "The Doorman"
local function sortHeroes(rows)
    local function getSortName(name)
        return (name:gsub("^The ", ""))
    end

    table.sort(rows, function(a, b)
        local nameA = getSortName(a.sortName or a.name or "")
        local nameB = getSortName(b.sortName or b.name or "")
        return nameA:lower() < nameB:lower()
    end)
end

-- Helper function to round numeric values
local function roundValue(value)
    if type(value) == "number" then
        return util_module.round_to_sig_fig(value, 3)
    end
    return value
end

-- Round to an integer (up to 2 digits)
local function roundValueInteger(value)
    if type(value) == "number" then
        return util_module.round_to_sig_fig(value, 2)
    end
    return value
end

-- Generate tables from given properties
local function buildTable(frame, rows, tableDef, options)
    options = options or {}
    -- option to ignore heroes who don't have any of the given properties. true by default
    local filterZero = options.filterZero or true

    local filteredRows = {}

    for _, r in ipairs(rows) do
        local hasValue = not filterZero

        if filterZero then
            for _, col in ipairs(tableDef.columns) do
                if r[col.field] ~= 0 and r[col.field] ~= nil then
                	if r[col.field] ~= "+0" then
                    hasValue = true
                    break
                    end
                end
            end
        end

        if hasValue then
            table.insert(filteredRows, r)
        end
    end

    if #filteredRows == 0 then
        return ""
    end

    -- Build header
    local header = ""
    for _, col in ipairs(tableDef.columns) do
        header = header .. " !! " .. col.label
    end

    -- Start table
    local t = "{| class=\"wikitable sortable mw-collapsible\" style=\"text-align:center\" \n" ..
        "|+ " .. tableDef.title .. " \n" ..
        "! Hero" .. header .. " \n"

    -- Rows
    for _, r in ipairs(filteredRows) do
        local cells = ""
        for _, col in ipairs(tableDef.columns) do
            cells = cells .. " || " .. (r[col.field] or 0)
        end

        -- Rows may carry an alternate sort key and a label suffix, used for
        -- secondary fire rows listed underneath their hero's main row
        local nameCell = "| style=\"text-align:left;\""
        if r.sortName then
            nameCell = nameCell .. " data-sort-value=\"" .. r.sortName .. "\""
        end
        nameCell = nameCell .. " | " ..
            frame:expandTemplate{ title = "PageRef", args = { r.name } } ..
            (r.nameSuffix or "")

        t = t ..
            "|- \n" ..
            nameCell ..
            cells .. " \n"
    end

    t = t .. "|}"

    return t
end

-- heroDataArray takes as argument a table of any length containing keys or 
-- paths of keys in the HeroData JSON, e.g. {Name, MaxHealth, LevelScaling>Health}.
-- A path of keys should be specified as such, with the ">" character separating
-- subsequent keys.
local heroDataArray = function(args)
    local outData = {}
    
    for i, heroData in ipairs(inGameHeroData) do            -- Iterate over each hero
        local out = {}                                      -- First creating a table to hold data
        for j, key in pairs(args) do                        -- Iterate over each key:
            if string.find(key, ">") ~= nil then            -- If the key is actually a path of keys,
                local node = heroData                       -- start with the biggest node heroData, and
                for k in string.gmatch(key, "([^>]+)") do   -- iterate over each key, splitting on ">",
                    if node and node[k] ~= nil then         -- trying the key as a string e.g. "1", (Added 'node and' for safety)
                        node = node[k]                      
                    elseif node and node[tonumber(k)] ~= nil then -- else using the key as numeric, (Added 'node and' for safety)
                        node = node[tonumber(k)]
                    else                                    -- If at any point the path breaks, the result is nil
                        node = nil
                        break
                    end                                     -- drilling down the JSON node after node
                end
                out[key] = roundValue(node)                 -- until outputting the final node (rounded)
            else
                out[key] = roundValue(heroData[key])        -- or just grab the data if not a path (rounded)
            end
        end
        outData[heroData["Name"]] = out                     -- Save data in the big table of heroes
    end
    return(outData)
end

p.heroBulletDamageTable = function(frame)
    -- Pull data
    local heroBulletDamageData = heroDataArray({"Name", "Weapon>BulletDamage", "LevelScaling>BulletDamage", "Weapon>AltFire>BulletDamage", "LevelScaling>BulletDamageAltFire",
    	"SpiritScaling>BulletDamage", "Weapon>BulletsPerShot", "Weapon>BulletsPerBurst"})

    local rows = {}
    for name, data in pairs(heroBulletDamageData) do
        table.insert(rows, {
            name = name,
            bulletDamage = data["Weapon>BulletDamage"] or 0,
            bulletDamagePerShot = ((data["Weapon>BulletDamage"] or 0) * (data["Weapon>BulletsPerShot"] or 0) * (data["Weapon>BulletsPerBurst"] or 0)),
            scalingDamage = "+" .. data["LevelScaling>BulletDamage"] or 0,
            maxScalingDamage =  (roundValue(data["Weapon>BulletDamage"] + data["LevelScaling>BulletDamage"] * soul_unlock.get_max("PowerIncrease"))) or 0,
            altBulletDamage = data["Weapon>AltFire>BulletDamage"] or 0,
            spiritScalingDamage = "+" .. (data["SpiritScaling>BulletDamage"] or 0),
        })

        -- Secondary fire is listed as its own row, right under the hero's main row
        local altDamage = data["Weapon>AltFire>BulletDamage"]
        if altDamage and altDamage ~= 0 then
            local altScaling = data["LevelScaling>BulletDamageAltFire"] or 0
            table.insert(rows, {
                name = name,
                sortName = name .. " (alt fire)",
                nameSuffix = " (alt fire)",
                bulletDamage = altDamage,
                scalingDamage = "+" .. altScaling,
                maxScalingDamage = roundValue(altDamage + altScaling * soul_unlock.get_max("PowerIncrease")),
                spiritScalingDamage = "+0",
            })
        end
    end
    sortHeroes(rows)

    local tableDefs = {
    	{
            title   = "Base Bullet Damage Stats",
            columns = {
                { label = "Starting", field = "bulletDamage"  },
                { label = "Added per Boon", field = "scalingDamage"  },
                { label = "At Max Boon", field = "maxScalingDamage"  },
                { label = "Spirit Scaling", field = "spiritScalingDamage"  },
            }
        },
    }

    local output = {}
    for _, def in ipairs(tableDefs) do
        local t = buildTable(frame, rows, def, def.options)
        if t ~= "" then
            table.insert(output, t)
        end
    end

    return table.concat(output, "\n\n")
end

-- Kept for regression testing: an untouched table that also goes through
-- buildTable() and sortHeroes()
p.heroHealthTable = function(frame)
    -- Pull data
    local heroHealthData = heroDataArray({"Name", "MaxHealth", "LevelScaling>MaxHealth"})

    local rows = {}
    for name, data in pairs(heroHealthData) do
        table.insert(rows, {
            name = name,
            maxHealth = data["MaxHealth"] or 0,
            scalingHealth = "+" .. data["LevelScaling>MaxHealth"] or 0,
            maxScalingHealth = roundValue(data["MaxHealth"] + data["LevelScaling>MaxHealth"] * soul_unlock.get_max("PowerIncrease")) or 0
        })
    end
    sortHeroes(rows)

    local tableDefs = {
        {
            title = "Base Health Stats",
            columns = {
                { label = "Starting", field = "maxHealth" },
                { label = "Added per Boon", field = "scalingHealth" },
                { label = "At Max Boon", field = "maxScalingHealth" },
            }
        }
    }

local output = {}
    for _, def in ipairs(tableDefs) do
        local t = buildTable(frame, rows, def, def.options)
        if t ~= "" then
            table.insert(output, t)
        end
    end

    return table.concat(output, "\n\n")
end

return p