Editing Module:Sandbox/LVL

Warning: You are not logged in. Once you make an edit, a temporary account will be created for you. Learn more. Log in or create an account to continue receiving notifications after this account expires, and to access other features.
The edit can be undone. Please check the comparison below to verify that this is what you want to do, and then publish the changes below to finish undoing the edit.
Latest revision Your text
Line 1: Line 1:
local p = {}
local Tabs = {}


local heroes_data      = mw.loadJsonData("Data:HeroData.json")
--[[
local attributes_data  = mw.loadJsonData("Data:AttributeData.json")
=================================================================
local attribute_orders  = mw.loadJsonData("Data:StatInfoboxOrder.json")
Start of dependency: Module:Table
local util_module      = require('Module:Utilities')
=================================================================
local lang_module      = require('Module:Lang')
]]
local dictionary_module = require('Module:Dictionary')
local Table = {}
local attribute_module  = require('Module:AttributeData')
local hero_data_module  = require('Module:HeroData')


local function get_nested_stat_value(hero_data, stat_key)
function Table.size(tbl)
    if hero_data[stat_key] ~= nil then
local i = 0
        return hero_data[stat_key]
for _ in pairs(tbl) do
    elseif hero_data.Weapon and hero_data.Weapon[stat_key] ~= nil then
i = i + 1
        return hero_data.Weapon[stat_key]
end
    elseif hero_data.Weapon and hero_data.Weapon.AltFire and hero_data.Weapon.AltFire[stat_key] ~= nil then
return i
        return hero_data.Weapon.AltFire[stat_key]
    end
    return 0
end
end


local function localize(key, fallback)
function Table.includes(tbl, value, isPattern)
    local result = lang_module.get_string(key)
for _, entry in pairs(tbl) do
    if result == "" or result == nil then
if isPattern and string.find(entry, value)
        result = util_module.add_space_before_cap(fallback) ..
or not isPattern and entry == value then
                mw.getCurrentFrame():expandTemplate{title = "MissingValveTranslationTooltip"}
return true
    end
end
    return result
end
return false
end
end


-- =========================================================================
function Table.getKeyOfValue(tbl, value)
--  DPS calculation helpers
for key, entry in pairs(tbl) do
-- =========================================================================
if entry == value then
return key
end
end
return nil
end
 
function Table.filter(tbl, predicate, argument)
local filteredTbl = {}
local foundMatches = 1
 
for _, entry in pairs(tbl) do
if predicate(entry, argument) then
filteredTbl[foundMatches] = entry
foundMatches = foundMatches + 1
end
end
 
return filteredTbl
end
 
function Table.filterByKey(tbl, predicate)
local filteredTbl = {}
 
for key, entry in pairs(tbl) do
if predicate(key, entry) then
filteredTbl[key] = entry
end
end


local function calculate_dps(stats, dps_type)
return filteredTbl
    local rps = stats.RoundsPerSecond or 0
end
    if rps == 0 then return 0 end


    local bullets_per_shot = (stats.HitOnceAcrossAllBullets and 1) or (stats.BulletsPerShot or 1)
function Table.isEmpty(tbl)
    local burst_count = stats.BulletsPerBurst or 1
if tbl == nil then
    local cycle_time = 1 / rps
return true
    local total_cycle_time = cycle_time * burst_count
end
for _, _ in pairs(tbl) do
return false
end
return true
end


    if total_cycle_time == 0 then return 0 end
function Table.isNotEmpty(tbl)
return not Table.isEmpty(tbl)
end


    local base_damage = stats.BulletDamage or 0
function Table.copy(tbl)
    if dps_type == 'burst' then
local result = {}
        return base_damage * bullets_per_shot * burst_count / total_cycle_time
for key, entry in pairs(tbl) do
    end
result[key] = entry
end
return result
end


    local clip_size = stats.ClipSize or 0
function Table.deepCopy(tbl_, options)
    if clip_size <= 0 then
options = options or {}
        return base_damage * bullets_per_shot * burst_count / total_cycle_time
assert(type(tbl_) == 'table', 'Table.deepCopy: Input must be a table')
    end


    local reload_time
local function deepCopy(tbl)
    if stats.ReloadSingle then
local result = {}
        reload_time = (stats.ReloadTime or 0) * clip_size
for key, value in pairs(tbl) do
    else
result[key] = type(value) == 'table' and deepCopy(value) or value
        reload_time = stats.ReloadTime or 0
end
    end
if options.copyMetatable then
    reload_time = reload_time + (stats.ReloadDelay or 0)
local metatable = getmetatable(tbl)
if type(metatable) == 'table' then
setmetatable(result, deepCopy(metatable))
end
end
return result
end


    local time_to_empty_clip = (clip_size / burst_count) * total_cycle_time
return deepCopy(tbl_)
    local damage_from_clip = base_damage * bullets_per_shot * clip_size
    local total_time = time_to_empty_clip + reload_time
    if total_time == 0 then return 0 end
    return damage_from_clip / total_time
end
end


-- Compute how much DPS changes when all relevant component scalings are applied.
function Table.deepEquals(xTable, yTable)
-- Used for alt-fire rows where explicit DPS/SustainedDPS scaling keys are missing.
assert(type(xTable) == 'table', 'Table.deepEquals: First argument must be a table')
local function compute_dps_scaling(hero_data, base_stats, dps_type, scaling_type)
assert(type(yTable) == 'table', 'Table.deepEquals: Second argument must be a table')
    local scaling_source = hero_data[scaling_type .. "Scaling"] or {}
 
for key, value in pairs(xTable) do
if not Logic.deepEquals(value, yTable[key]) then
return false
end
end


    local component_scalings = {}
for key, _ in pairs(yTable) do
    local possible_keys = {
if xTable[key] == nil then
        "BulletDamage", "RoundsPerSecond", "ClipSize", "ReloadTime",
return false
        "ReloadDelay", "BulletsPerShot", "BulletsPerBurst"
end
    }
end
    for _, key in ipairs(possible_keys) do
        local alt_key = key .. "AltFire"
        if scaling_source[alt_key] then
            component_scalings[key] = scaling_source[alt_key]
        end
    end


    -- ClipSize is shared between fire modes; fall back to primary scaling
return true
    if base_stats.ClipSize and not component_scalings.ClipSize then
end
        local primary_clip_scale = scaling_source["ClipSize"]
        if primary_clip_scale then
            component_scalings.ClipSize = primary_clip_scale
        end
    end


    if next(component_scalings) == nil then
function Table.mergeInto(target, ...)
        return 0
local objs = Table.pack(...)
    end
for i = 1, objs.n do
if type(objs[i]) == 'table' then
for key, value in pairs(objs[i]) do
target[key] = value
end
end
end
return target
end


    local base_dps = calculate_dps(base_stats, dps_type)
function Table.merge(...)
    local scaled_stats = {}
return Table.mergeInto({}, ...)
    for k, v in pairs(base_stats) do scaled_stats[k] = v end
    for comp_key, scale_val in pairs(component_scalings) do
        if scaled_stats[comp_key] ~= nil then
            scaled_stats[comp_key] = scaled_stats[comp_key] + scale_val
        end
    end
    local scaled_dps = calculate_dps(scaled_stats, dps_type)
    return scaled_dps - base_dps
end
end


-- =========================================================================
function Table.deepMergeInto(target, ...)
--  Main module function
local tbls = Table.pack(...)
-- =========================================================================
for i = 1, tbls.n do
if type(tbls[i]) == 'table' then
for key, value in pairs(tbls[i]) do
if type(target[key]) == 'table' and type(value) == 'table' then
Table.deepMergeInto(target[key], value)
else
target[key] = value
end
end
end
end
return target
end


p.write_hero_comparison_table = function(frame)
function Table.deepMerge(...)
    local power_increases = tonumber(frame.args[1]) or 0
return Table.deepMergeInto({}, ...)
    local spirit_power    = tonumber(frame.args[2]) or 0
end
    local max_power      = tonumber(frame.args[3]) or 25
    local max_spirit      = tonumber(frame.args[4]) or 500


    local display_scaling_icons = (power_increases == 0 and spirit_power == 0)
function Table.map(xTable, f)
local yTable = {}
for xKey, xValue in pairs(xTable) do
local yKey, yValue = f(xKey, xValue)
yTable[yKey] = yValue
end
return yTable
end


    local body_str = ""
function Table.mapArgumentsByPrefix(args, prefixes, f, noInterleave)
local function indexFromKey(key)
local prefix, index = key:match('^([%a_]+)(%d+)$')
if Table.includes(prefixes, prefix) then
return tonumber(index), prefix
else
return nil
end
end


    local stats_to_include = {
return Table.mapArguments(args, indexFromKey, f, noInterleave)
        Weapon = {
end
            "DPS", "SustainedDPS", "BulletDamage", "RoundsPerSecond", "FireRate",
            "ClipSize", "ReloadTime", "ReloadDelay", "ReloadSingle", "BulletsPerShot", "BulletsPerBurst",
            "BurstInterShotInterval", "LightMeleeDamage", "HeavyMeleeDamage",
            "BulletSpeed", "FalloffStartRange", "FalloffEndRange",
            "CritDamageBonusPercent", "BulletRadius", "RoundsPerSecondAtMaxSpin", "SpinAcceleration", "SpinDeceleration"
        },
        Vitality = {
            "MaxHealth", "BaseHealthRegen", "BulletResist", "TechResist", "MeleeResist",
            "CritDamageReceivedPercent", "DebuffResist", "BulletLifesteal", "MaxMoveSpeed",
            "SprintSpeed", "StaminaCooldown", "Stamina", "GroundDashSpeed", "GravityChange"
        },
        Spirit = { "TechPower" }
    }


    -- Collect and sort heroes alphabetically, excluding disabled/in-development
function Table.mapArguments(args, indexFromKey, f, noInterleave)
    local sorted_heroes = {}
local entriesByIndex = {}
    for hero_key, hero_data in pairs(heroes_data) do
        if not hero_data["InDevelopment"] and not hero_data["IsDisabled"] then
            table.insert(sorted_heroes, {key = hero_key, data = hero_data})
        end
    end
    table.sort(sorted_heroes, function(a, b)
        local function get_sort_name(name)
            return (name:gsub("^The ", ""))
        end
        return get_sort_name(a.data["Name"] or a.key) < get_sort_name(b.data["Name"] or b.key)
    end)


    -- Build a single stat cell (<td>) with data attributes for JS recalculation
-- Non-numeric args
    local function buildStatCell(hero_data, hero_key, attr_key, is_alt_fire, category)
for key, _ in pairs(args) do
        -- Conversion factors for stats that need unit changes (e.g. metres → centimetres)
local function post(index, ...)
        local unit_conversion = {
if index and not entriesByIndex[index] then
            BulletRadius = 100  -- metres to centimetres
entriesByIndex[index] = f(key, index, ...)
        }
end
        local conv_factor = unit_conversion[attr_key] or 1
end
if type(key) == 'string' then
post(indexFromKey(key))
end
end


        local base_value
if noInterleave then
        if is_alt_fire then
return entriesByIndex
            if attr_key == "ClipSize" or attr_key == "ReloadTime" then
end
                base_value = get_nested_stat_value(hero_data, attr_key)
            elseif hero_data.Weapon and hero_data.Weapon.AltFire
                and hero_data.Weapon.AltFire[attr_key] ~= nil then
                base_value = hero_data.Weapon.AltFire[attr_key]
            else
                base_value = get_nested_stat_value(hero_data, attr_key)
            end
        else
            base_value = get_nested_stat_value(hero_data, attr_key)
        end


        -- Apply unit conversion to the base value
-- Numeric index entries fills in gaps of prefixN= entries if not disabled
        if type(base_value) == "number" then
local entryIndex = 1
            base_value = base_value * conv_factor
for argIndex = 1, math.huge do
        end
if not args[argIndex] then
break
end
while entriesByIndex[entryIndex] do
entryIndex = entryIndex + 1
end
entriesByIndex[entryIndex] = f(argIndex, entryIndex)
end


        local stat_value = base_value
return entriesByIndex
end


        -- Scaling lookup
function Table.mapValues(xTable, f)
        local scaling_data
local yTable = {}
        if is_alt_fire then
for xKey, xValue in pairs(xTable) do
            if attr_key == "ClipSize" or attr_key == "ReloadTime" then
yTable[xKey] = f(xValue)
                scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key)
end
            elseif (attr_key == "DPS" or attr_key == "SustainedDPS")
return yTable
                and hero_data.Weapon and hero_data.Weapon.AltFire then
end
                -- Alt-fire DPS/SustainedDPS: use explicit key if present, otherwise compute from components
                local direct_scaling = hero_data_module.get_hero_scaling_data(hero_data, attr_key .. "AltFire")
                if direct_scaling and next(direct_scaling) ~= nil then
                    scaling_data = direct_scaling
                else
                    local alt_stats = hero_data.Weapon.AltFire
                    local dps_type = attr_key == "DPS" and 'burst' or 'sustained'
                    local level_scale = compute_dps_scaling(hero_data, alt_stats, dps_type, "Level")
                    local spirit_scale = compute_dps_scaling(hero_data, alt_stats, dps_type, "Spirit")
                    level_scale = util_module.round_to_sig_fig(level_scale, 5)
                    spirit_scale = util_module.round_to_sig_fig(spirit_scale, 5)
                    scaling_data = {}
                    if level_scale ~= 0 then scaling_data[level_scale] = "Level" end
                    if spirit_scale ~= 0 then scaling_data[spirit_scale] = "Spirit" end
                    if next(scaling_data) == nil then scaling_data = nil end
                end
            elseif hero_data.Weapon and hero_data.Weapon.AltFire
                and hero_data.Weapon.AltFire[attr_key] ~= nil then
                scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key .. "AltFire")
            else
                scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key)
            end
        else
            scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key)
        end


        local scaling_strs = ""
function Table.all(tbl, predicate)
        local spirit_scale = 0
for key, value in pairs(tbl) do
        local level_scale  = 0
if not predicate(key, value) then
        local spirit_val = 0
return false
        local level_val  = 0
end
        if scaling_data ~= nil then
end
            for scaling_val, scaling_type in pairs(scaling_data) do
return true
                if scaling_type == "Spirit" then
end
                    spirit_val = scaling_val * conv_factor  -- apply unit conversion
                elseif scaling_type == "Level" then
                    level_val = scaling_val * conv_factor  -- apply unit conversion
                end
            end


            -- Output Spirit icon before Level icon
function Table.any(tbl, predicate)
            local function append_scaling(val, stype)
for key, value in pairs(tbl) do
                if val ~= 0 then
if predicate(key, value) then
                    local scaling_str = hero_data_module.write_scalar_str(val, stype, true)
return true
                    if scaling_str ~= "" then
end
                        scaling_strs = scaling_strs .. " " .. scaling_str
end
                    end
return false
                end
end
            end
            append_scaling(spirit_val, "Spirit")
            append_scaling(level_val, "Level")


            if type(stat_value) == "number" then
function Table.groupBy(tbl, f)
                stat_value = stat_value + (spirit_power * spirit_val)
local groups = {}
                stat_value = stat_value + (power_increases * level_val)
for key, value in pairs(tbl) do
            end
local groupKey = f(key, value)
if not groups[groupKey] then
groups[groupKey] = {}
end
groups[groupKey][key] = value
end
return groups
end


            spirit_scale = spirit_val
function Table.extract(tbl, key)
            level_scale  = level_val
local value = tbl[key]
        end
tbl[key] = nil
return value
end


        if attr_key == "TechPower" and spirit_scale == 0 then
function Table.getByPathOrNil(tbl, path)
            spirit_scale = 1.0
for _, fieldName in ipairs(path) do
            if type(stat_value) == "number" then
if type(tbl) ~= 'table' then
                stat_value = stat_value + (spirit_power * spirit_scale)
return nil
            end
end
        end
tbl = tbl[fieldName]
end
return tbl
end
 
function Table.setByPath(tbl, path, value)
for i = 1, #path - 1 do
if tbl[path[i]] == nil then
tbl[path[i]] = {}
end
tbl = tbl[path[i]]
end
tbl[path[#path]] = value
end
 
function Table.uniqueKey(tbl)
local key0 = nil
for key, _ in pairs(tbl) do
if key0 ~= nil then return nil end
key0 = key
end
return key0
end
 
function Table.entries(tbl)
local entries = {}
for key, value in pairs(tbl) do
table.insert(entries, {key, value})
end
return entries
end
 
function Table.pack(...)
return {n = select('#', ...), ...}
end
 
Table.iter = {}
 
function Table.iter.spairs(tbl, order)
local keys = {}
for k in pairs(tbl) do keys[#keys+1] = k end
 
if order then
table.sort(keys, function(a,b) return order(tbl, a, b) end)
else
table.sort(keys)
end
 
local i = 0
return function()
i = i + 1
if keys[i] then
return keys[i], tbl[keys[i]]
end
end
end
 
function Table.iter.pairsByPrefix(tbl, prefixes, options)
options = options or {}
if type(prefixes) == 'string' then
prefixes = {prefixes}
end
 
local getByPrefixes = function(index)
for _, prefix in ipairs(prefixes) do
local key = prefix .. index
if tbl[key] then
return key, tbl[key]
end
end
end
 
local i = 1
return function()
local key, value = getByPrefixes(i)
if options.requireIndex == false and i == 1 and not value then
key, value = getByPrefixes('')
end
i = i + 1
if value then
return key, value, (i - 1)
else
return nil
end
end
end
 
--[[
=================================================================
Start of dependency: Module:Logic
=================================================================
]]
local Logic = {}
 
function Logic.emptyOr(val1, val2, default)
if not Logic.isEmpty(val1) then
return val1
elseif not Logic.isEmpty(val2) then
return val2
else
return default
end
end
 
function Logic.nilOr(...)
local args = Table.pack(...)
for i = 1, args.n do
local arg = args[i]
local val
if type(arg) == 'function' then
val = arg()
else
val = arg
end
if val ~= nil then
return val
end
end
return nil
end
 
function Logic.isEmpty(val)
if type(val) == 'table' then
return Table.isEmpty(val)
else
return val == '' or val == nil
end
end
 
function Logic.isNotEmpty(val)
if type(val) == 'table' then
return Table.isNotEmpty(val)
else
return val ~= nil and val ~= ''
end
end
 
function Logic.nilIfEmpty(val)
return Logic.isNotEmpty(val) and val or nil
end
 
function Logic.isDeepEmpty(val)
return Logic.isEmpty(val) or type(val) == 'table' and
Table.all(val, function(key, item) return Logic.isDeepEmpty(item) end)
end
 
function Logic.isNotDeepEmpty(val)
return not Logic.isDeepEmpty(val)
end
 
function Logic.readBool(val)
return val == 'true' or val == 't' or val == 'yes' or val == 'y' or val == true or val == '1' or val == 1
end
 
function Logic.readBoolOrNil(val)
if Logic.readBool(val) then
return true
elseif val == 'false' or val == 'f' or val == 'no' or val == 'n' or val == false or val == '0' or val == 0 then
return false
else
return nil
end
end
 
function Logic.nilThrows(val)
if val == nil then
error('Unexpected nil', 2)
end
return val
end
 
function Logic.tryCatch(try, catch)
local ran, result = pcall(try)
if not ran then
catch(result)
else
return result
end
end
 
function Logic.deepEquals(x, y)
if x == y then
return true
elseif type(x) == 'table' and type(y) == 'table' then
return Table.deepEquals(x, y)
else
return false
end
end
 
--[[
=================================================================
Start of dependency: Module:Array
=================================================================
]]
local Array = {}
 
function Array.randomize(tbl)
math.randomseed(os.time())
for i = #tbl, 2, -1 do
local j = math.random(i)
tbl[i], tbl[j] = tbl[j], tbl[i]
end
return tbl
end
 
function Array.isArray(tbl)
return type(tbl) == 'table' and Table.size(tbl) == #tbl
end
 
function Array.copy(tbl)
local copy = {}
for _, element in ipairs(tbl) do
table.insert(copy, element)
end
return copy
end
 
function Array.sub(tbl, startIndex, endIndex)
if startIndex < 0 then startIndex = #tbl + 1 + startIndex end
if not endIndex then endIndex = #tbl end
if endIndex < 0 then endIndex = #tbl + 1 + endIndex end
 
local subArray = {}
for index = startIndex, endIndex do
table.insert(subArray, tbl[index])
end
return subArray
end
 
function Array.map(elements, funct)
local mappedArray = {}
for index, element in ipairs(elements) do
local mappedElement = funct(element, index)
table.insert(mappedArray, mappedElement)
end
return mappedArray
end
 
function Array.filter(tbl, predicate)
local filteredArray = {}
for index, element in ipairs(tbl) do
if predicate(element, index) then
table.insert(filteredArray, element)
end
end
return filteredArray
end
 
function Array.flatten(tbl)
local flattenedArray = {}
for _, x in ipairs(tbl) do
if type(x) == 'table' then
for _, y in ipairs(x) do
table.insert(flattenedArray, y)
end
else
table.insert(flattenedArray, x)
end
end
return flattenedArray
end
 
function Array.flatMap(elements, funct)
return Array.flatten(Array.map(elements, funct))
end
 
function Array.all(tbl, predicate)
for _, element in ipairs(tbl) do
if not predicate(element) then
return false
end
end
return true
end
 
function Array.any(tbl, predicate)
for _, element in ipairs(tbl) do
if predicate(element) then
return true
end
end
return false
end
 
function Array.find(tbl, predicate)
for index, element in ipairs(tbl) do
if predicate(element, index) then
return element
end
end
return nil
end
 
function Array.groupBy(tbl, funct)
local groupsByKey = {}
local groups = {}
for _, xValue in ipairs(tbl) do
local yValue = funct(xValue)
if yValue then
local group = groupsByKey[yValue]
if not group then
group = {}
groupsByKey[yValue] = group
table.insert(groups, group)
end
table.insert(group, xValue)
end
end
return groups, groupsByKey
end
 
function Array.groupAdjacentBy(array, f, equals)
equals = equals or Logic.deepEquals
local groups = {}
local currentKey
for index, elem in ipairs(array) do
local key = f(elem)
if index == 1 or not equals(key, currentKey) then
currentKey = key
table.insert(groups, {})
end
table.insert(groups[#groups], elem)
end
return groups
end
 
function Array.lexicalCompare(tblX, tblY)
for index = 1, math.min(#tblX, #tblY) do
if tblX[index] < tblY[index] then
return true
elseif tblX[index] > tblY[index] then
return false
end
end
return #tblX < #tblY
end
 
function Array.lexicalCompareIfTable(y1, y2)
if type(y1) == 'table' and type(y2) == 'table' then
return Array.lexicalCompare(y1, y2)
else
return y1 < y2
end
end
 
function Array.sortBy(tbl, funct, compare)
local copy = Table.copy(tbl)
Array.sortInPlaceBy(copy, funct, compare)
return copy
end
 
function Array.sortInPlaceBy(tbl, funct, compare)
compare = compare or Array.lexicalCompareIfTable
table.sort(tbl, function(x1, x2) return compare(funct(x1), funct(x2)) end)
end
 
function Array.reverse(tbl)
local reversedArray = {}
for index = #tbl, 1, -1 do
table.insert(reversedArray, tbl[index])
end
return reversedArray
end
 
function Array.append(tbl, ...)
return Array.appendWith(Array.copy(tbl), ...)
end
 
function Array.appendWith(tbl, ...)
local elements = Table.pack(...)
for index = 1, elements.n do
if elements[index] ~= nil then
table.insert(tbl, elements[index])
end
end
return tbl
end
 
function Array.extend(tbl, ...)
return Array.extendWith({}, tbl, ...)
end
 
function Array.extendWith(tbl, ...)
local arrays = Table.pack(...)
for index = 1, arrays.n do
if type(arrays[index]) == 'table' then
for _, element in ipairs(arrays[index]) do
table.insert(tbl, element)
end
elseif arrays[index] ~= nil then
table.insert(tbl, arrays[index])
end
end
return tbl
end
 
function Array.mapIndexes(funct)
local arr = {}
for index = 1, math.huge do
local y = funct(index)
if y then
table.insert(arr, y)
else
break
end
end
return arr
end
 
function Array.range(from, to)
local elements = {}
for element = from, to do
table.insert(elements, element)
end
return elements
end
 
function Array.extractKeys(tbl, iterator, ...)
iterator = iterator or pairs
local array = {}
for key, _ in iterator(tbl, ...) do
table.insert(array, key)
end
return array
end
 
function Array.extractValues(tbl, iterator, ...)
iterator = iterator or pairs
local array = {}
for _, item in iterator(tbl, ...) do
table.insert(array, item)
end
return array
end
 
function Array.forEach(elements, funct)
for index, element in ipairs(elements) do
funct(element, index)
end
end
 
function Array.reduce(array, operator, initialValue)
local aggregate
if initialValue ~= nil then
aggregate = initialValue
else
aggregate = array[1]
end
 
for index = initialValue ~= nil and 1 or 2, #array do
aggregate = operator(aggregate, array[index])
end
return aggregate
end
 
function Array.maxBy(array, funct, compare)
compare = compare or Array.lexicalCompareIfTable
local max, maxScore
for _, item in ipairs(array) do
local score = funct(item)
if max == nil or compare(maxScore, score) then
max = item
maxScore = score
end
end
return max
end
 
function Array.max(array, compare)
return Array.maxBy(array, function(x) return x end, compare)
end
 
function Array.minBy(array, funct, compare)
compare = compare or Array.lexicalCompareIfTable
local min, minScore
for _, item in ipairs(array) do
local score = funct(item)
if min == nil or compare(score, minScore) then
min = item
minScore = score
end
end
return min
end
 
function Array.min(array, compare)
return Array.minBy(array, function(x) return x end, compare)
end
 
function Array.indexOf(array, pred)
for ix, elem in ipairs(array) do
if pred(elem, ix) then
return ix
end
end
return 0
end
 
function Array.unique(elements)
local elementCache = {}
local uniqueElements = {}
for _, element in ipairs(elements) do
if elementCache[element] == nil then
table.insert(uniqueElements, element)
elementCache[element] = true
end
end
return uniqueElements
end
 
function Array.parseCommaSeparatedString(inputString, sep)
if Logic.isEmpty(inputString) then return {} end
return Array.map(mw.text.split(inputString, sep or ','), mw.text.trim)
end
 
function Array.interleave(elements, x)
local size = #elements
return Array.flatMap(elements, function(element, index)
if index == size then
return {element}
end
return {element, x}
end)
end
 
--[[
=================================================================
Start of dependency: Module:Operator
=================================================================
]]
local Operator = {}
 
function Operator.add(a, b) return a + b end
function Operator.sub(a, b) return a - b end
function Operator.mul(a, b) return a * b end
function Operator.div(a, b) return a / b end
function Operator.pow(a, b) return a ^ b end
function Operator.eq(a, b) return a == b end
function Operator.neq(a, b) return a ~= b end
function Operator.lt(a, b) return a < b end
function Operator.le(a, b) return a <= b end
function Operator.gt(a, b) return a > b end
function Operator.ge(a, b) return a >= b end
 
function Operator.property(item)
assert(type(item) == 'string' or type(item) == 'number', 'Invalid or missing input to `Operator.property`')
local pathSegments = mw.text.split(item, '.', true)
return function(tbl)
local selected = tbl
for segmentIndex, pathSegment in ipairs(pathSegments) do
if type(selected) ~= 'table' and segmentIndex == 1 then
error('Nil supplied to `Operator.property(' .. item .. ')`')
elseif type(selected) ~= 'table' then
local pathUntilHere = Array.sub(pathSegments, 1, segmentIndex - 1)
error('Could not index "tbl.' .. table.concat(pathUntilHere, '.') .. '"')
end
selected = selected[pathSegment] or selected[tonumber(pathSegment)]
end
return selected
end
end
 
function Operator.method(funcName, ...)
local args = {...}
return function(obj)
return obj[funcName](obj, unpack(args))
end
end
 
--[[
=================================================================
Start of dependency: Module:Page
=================================================================
]]
local Page = {}
 
function Page.exists(link)
local existingPage = mw.title.new(link)
if existingPage == nil then
return false
end
return existingPage.exists
end
 
function Page.makeInternalLink(options, display, customLink)
if type(options) == 'string' then
customLink = display
display = options
end
if Logic.isEmpty(display) then
return nil
elseif Logic.isEmpty(customLink) then
customLink = display
end
 
if (options or {}).onlyIfExists == true and (not Page.exists(customLink)) then
return nil
end
 
return '[[' .. customLink .. '|' .. display .. ']]'
end
 
function Page.makeExternalLink(display, link)
if Logic.isEmpty(display) or Logic.isEmpty(link) then
return nil
end
return '[' .. link .. ' ' .. display .. ']'
end
 
function Page.pageifyLink(link)
if Logic.isEmpty(link) then
return nil
end
return (mw.ext.TeamLiquidIntegration.resolve_redirect(link):gsub(' ', '_'))
end
 
--[[
=================================================================
Start of original Module:Tabs code
=================================================================
]]
function Tabs._readArguments(args, options)
local tabArgs = {}
local tabIndex = 1
local this = tonumber(args.This)
local this2 = tonumber(args.This2)
 
while args['name' .. tabIndex] or args['link' .. tabIndex] do
if args['content' .. tabIndex] or not options.removeEmptyTabs then
table.insert(tabArgs, {
name = Table.extract(args, 'name' .. tabIndex),
link = Table.extract(args, 'link' .. tabIndex),
content = Table.extract(args, 'content' .. tabIndex),
tabs = Table.extract(args, 'tabs' .. tabIndex),
this = this == tabIndex or (options.allowThis2 and this2 == tabIndex),
})
end
tabIndex = tabIndex + 1
end
 
if Logic.readBool(args.returnIfEmpty) then
return tabArgs
end
 
assert(Logic.isNotEmpty(tabArgs), 'You are trying to add a "Tabs" template without arguments for names nor links')
 
return tabArgs
end
 
function Tabs._setThis(tabArgs)
if Array.any(tabArgs, Operator.property('this')) then return end
 
local fullPageName = mw.title.getCurrentTitle().prefixedText
local this
local maxLinkLength = -1
 
Array.forEach(tabArgs, function (tab, tabIndex)
local link = tab.link
if not link then return end
link = link:gsub('_', ' ')
local linkLength = string.len(link)
local charAfter = string.sub(fullPageName, linkLength + 1, linkLength + 1)
local pagePartial = string.sub(fullPageName, 1, linkLength)
if pagePartial == link and (charAfter == '/' or charAfter == '') and linkLength > maxLinkLength then
maxLinkLength = linkLength
this = tabIndex
end
end)
 
if not this then return end
tabArgs[this].this = true
end
 
function Tabs._buildContentDiv(hasContent, hybridTabs, noPadding)
if hasContent then
local contentDiv = mw.html.create('div')
:addClass('tabs-content')
if hybridTabs then
contentDiv
:css('border-style', 'none !important')
:css('padding', '0 !important')
elseif noPadding then
contentDiv
:css('padding', '0 !important')
end
return contentDiv
end
 
local style = ''
if hybridTabs then
style = 'border-style:none !important; padding:0 !important;'
elseif noPadding then
style = 'padding:0 !important;'
end
return '\n<div class="tabs-content" style="' .. style .. '">'
end
 
function Tabs._single(tab, showHeader)
local header
if showHeader then
header = mw.html.create()
:tag('h6'):wikitext(tab.name):done()
:newline()
end
return mw.html.create()
:node(header)
:node(tab.content)
end
 
function Tabs._getDisplayNameFromLink(link)
local linkParts = mw.text.split(link, '/', true)
return linkParts[#linkParts]
end
 
function Tabs.static(args)
args = args or {}
 
local tabArgs = Tabs._readArguments(args, {allowThis2 = true})
local tabCount = #tabArgs
if tabCount == 0 then return end
 
Tabs._setThis(tabArgs)
 
local tabs = mw.html.create('ul')
:attr('class', 'nav nav-tabs navigation-not-searchable tabs tabs' .. tabCount)
:attr('data-nosnippet')
 
local subTabs = mw.html.create()
 
Array.forEach(tabArgs, function(tab)
local name = tab.name or Tabs._getDisplayNameFromLink(tab.link)
local text = tab.link and Page.makeInternalLink({}, name, tab.link) or tab.name
tabs:tag('li'):addClass(tab.this and 'active' or nil):wikitext(text)
subTabs:node(tab.this and tab.tabs or nil)
end)
 
return mw.html.create()
:tag('div')
:addClass('tabs-static')
:attr('data-nosnippet', '')
:node(tabs)
:done()
:node(subTabs)
end


        local innate_spirit_scale =
function Tabs.dynamic(args)
            (hero_data["LevelScaling"] and hero_data["LevelScaling"]["TechPower"]) or 0
args = args or {}


        if not display_scaling_icons then scaling_strs = "" end
local tabArgs = Tabs._readArguments(args, {removeEmptyTabs = Logic.readBool(args.removeEmptyTabs)})
local tabCount = #tabArgs
if tabCount == 0 then return end


        if type(stat_value) == "boolean" then
local hasContent = Array.all(tabArgs, function(tab)
            stat_value = tostring(stat_value)
return Logic.isNotEmpty(tab.content) end)
        else
local allEmpty = Array.all(tabArgs, function(tab)
            stat_value = util_module.round_to_sig_fig(stat_value, 5)
return Logic.isEmpty(tab.content) end)
        end
assert(hasContent or allEmpty, 'Some of the tabs have contents while others do not')


        local cell_inner = string.format(
local isSingular = tabCount == 1 and hasContent
            '<span class="stat-num">%s</span><span class="stat-scaling">%s</span>',
if isSingular and not Logic.readBool(args.showSingularAsTab) then
            stat_value,
return Tabs._single(tabArgs[1], not Logic.readBool(args.suppressHeader))
            scaling_strs
end
        )


        local weapon_table = hero_data.Weapon
local tabs = mw.html.create('ul')
        if is_alt_fire and weapon_table and weapon_table.AltFire then
:addClass('nav nav-tabs tabs tabs' .. tabCount)
            weapon_table = weapon_table.AltFire
        end
        local hit_once = "false"
        if (attr_key == "DPS" or attr_key == "SustainedDPS")
            and weapon_table
            and weapon_table.HitOnceAcrossAllBullets
        then
            hit_once = "true"
        end


        local data_attrs = string.format(
if not Array.any(tabArgs, Operator.property('this')) then
            'data-stat-name="%s" data-base="%s" data-spirit-scale="%s" data-level-scale="%s" data-innate-spirit-scale="%s" data-sort-value="%s" data-hit-once="%s"',
tabArgs[1].this = true
            attr_key,
end
            tostring(type(base_value) == "number" and util_module.round_to_sig_fig(base_value, 3) or base_value),
            tonumber(spirit_scale) or 0,
            (attr_key == "TechPower") and "0" or (tonumber(level_scale) or 0),
            innate_spirit_scale or 0,
            stat_value,
            hit_once
        )


        return string.format('<td style="white-space: nowrap;" %s>%s</td>', data_attrs, cell_inner)
local build = function(obj, elementType, content, class, isActive)
    end
local element = mw.html.create(elementType)
:addClass(class)
:addClass(isActive and 'active' or nil)
:newline()
:node(content)


    -- Generate a hero's primary stat row
obj:newline():node(element)
    local function generatePrimaryRow(hero_data, hero_key, has_alt)
end
        local row_str = ""
        local hero_name_local = localize(hero_key, hero_key)
        local hero_name_en = hero_data["Name"]
        local template_args = {[1] = hero_name_en, l1 = hero_name_local}
        local hero_icon = mw.getCurrentFrame():expandTemplate{ title = "Template:HeroIcon", args = template_args }
        local hero_cell_content = hero_icon
        if has_alt then
            hero_cell_content = '[[#alt-fire-' .. hero_key .. '|+]] ' .. hero_cell_content
        end
        local sort_name = (hero_data["Name"] or hero_key):gsub("^The ", "")
        row_str = row_str .. '<td style="position: sticky; left: 0; z-index: 10; background-color: var(--background-color-base-2); isolation: isolate; overflow: hidden; min-width: 150px;" data-sort-value="' .. sort_name .. '">' .. hero_cell_content .. '</td>'
        for _, category in ipairs(attribute_orders["category_order"]) do
            if stats_to_include[category] ~= nil then
                for _, attr_key in ipairs(stats_to_include[category]) do
                    row_str = row_str .. buildStatCell(hero_data, hero_key, attr_key, false, category)
                end
            end
        end
        return "<tr>" .. row_str .. "</tr>"
    end


    -- Generate an expandable alt-fire sub-row (weapon stats only)
Array.forEach(tabArgs, function(tabData, tabIndex)
    local function generateAltFireSubRow(hero_data, hero_key)
build(tabs, 'li', tabData.name, 'tab' .. tabIndex, tabData.this)
        local row_str = ""
end)
        local hero_name_local = localize(hero_key, hero_key)
        local hero_name_en = hero_data["Name"]
        local template_args = {[1] = hero_name_en, l1 = hero_name_local}
        local hero_icon = mw.getCurrentFrame():expandTemplate{ title = "Template:HeroIcon", args = template_args }
        local hero_cell_content = hero_icon .. ' <small style="color:#aaa;">(Alt‑fire)</small>'
        local sort_name = (hero_data["Name"] or hero_key):gsub("^The ", "")
        row_str = row_str .. '<td style="position: sticky; left: 0; z-index: 9; background-color: #202122; isolation: isolate; overflow: hidden; min-width: 150px;" data-sort-value="' .. sort_name .. '">' .. hero_cell_content .. '</td>'
        for _, category in ipairs(attribute_orders["category_order"]) do
            if stats_to_include[category] ~= nil then
                for _, attr_key in ipairs(stats_to_include[category]) do
                    if category == "Weapon" then
                        row_str = row_str .. buildStatCell(hero_data, hero_key, attr_key, true, category)
                    else
                        row_str = row_str .. '<td></td>'
                    end
                end
            end
        end
        return '<tr class="alt-fire-sub" data-parent="' .. hero_key .. '">' .. row_str .. '</tr>'
    end


    -- Build all body rows
if not Logic.nilOr(Logic.readBoolOrNil(args['hide-showall']), isSingular) then
    for _, hero_entry in ipairs(sorted_heroes) do
tabs:tag('li')
        local hero_key  = hero_entry.key
:addClass('show-all')
        local hero_data = hero_entry.data
:wikitext('Show All')
        local has_alt = hero_data.Weapon and hero_data.Weapon.AltFire and true or false
end
        body_str = body_str .. generatePrimaryRow(hero_data, hero_key, has_alt)
        if has_alt then
            body_str = body_str .. generateAltFireSubRow(hero_data, hero_key)
        end
    end


    -- Pre-pass: determine which stats have any scaling, and which have both types.
tabs:newline()
    -- Used to set column widths in the header.
    local stats_with_any_scaling  = {}
    local stats_with_both_scaling = {}
    for _, hero_entry in ipairs(sorted_heroes) do
        local hero_data = hero_entry.data
        for _, category in ipairs(attribute_orders["category_order"]) do
            if stats_to_include[category] ~= nil then
                for _, attr_key in ipairs(stats_to_include[category]) do
                    -- Primary scaling
                    local scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key)
                    if scaling_data ~= nil and next(scaling_data) ~= nil then
                        stats_with_any_scaling[attr_key] = true
                        local has_spirit, has_level = false, false
                        for _, scaling_type in pairs(scaling_data) do
                            if scaling_type == "Spirit" then has_spirit = true
                            elseif scaling_type == "Level" then has_level = true end
                        end
                        if has_spirit and has_level then stats_with_both_scaling[attr_key] = true end
                    end


                    -- Alt-fire scaling (only for stats present in the AltFire table)
local contents = Tabs._buildContentDiv(
                    if hero_data.Weapon
hasContent,
                        and hero_data.Weapon.AltFire
Logic.readBool(args['hybrid-tabs']),
                        and hero_data.Weapon.AltFire[attr_key] ~= nil
Logic.readBool(args['no-padding'])
                    then
)
                        if attr_key ~= "ClipSize" then
                            local alt_scaling = hero_data_module.get_hero_scaling_data(hero_data, attr_key .. "AltFire")
                            if alt_scaling and next(alt_scaling) ~= nil then
                                stats_with_any_scaling[attr_key] = true
                                local has_spirit_alt, has_level_alt = false, false
                                for _, stype in pairs(alt_scaling) do
                                    if stype == "Spirit" then has_spirit_alt = true
                                    elseif stype == "Level" then has_level_alt = true end
                                end
                                if has_spirit_alt and has_level_alt then stats_with_both_scaling[attr_key] = true end
                            else
                                if attr_key == "DPS" or attr_key == "SustainedDPS" then
                                    local alt_stats = hero_data.Weapon.AltFire
                                    local dps_type = attr_key == "DPS" and 'burst' or 'sustained'
                                    local lv = compute_dps_scaling(hero_data, alt_stats, dps_type, "Level")
                                    local sp = compute_dps_scaling(hero_data, alt_stats, dps_type, "Spirit")
                                    lv = util_module.round_to_sig_fig(lv, 5)
                                    sp = util_module.round_to_sig_fig(sp, 5)
                                    if lv ~= 0 or sp ~= 0 then
                                        stats_with_any_scaling[attr_key] = true
                                        if lv ~= 0 and sp ~= 0 then
                                            stats_with_both_scaling[attr_key] = true
                                        end
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end


    -- Build table header row
if not hasContent then
    local headers_str = '<th style="position: sticky; left: 0; top: -1px; z-index: 12; background-color: var(--background-color-base-5); isolation: isolate; min-width: 150px;">Hero</th>'
return '<div class="tabs-dynamic navigation-not-searchable" data-nosnippet>\n'
    local category_data = attribute_module.get_category_data()
.. tostring(tabs) .. contents
    local postfix_key_map = {
end
        ["ReloadDelay"] = "StatDesc_ReloadTime_postfix",
        ["BulletsPerShot"] = "",
        ["BulletsPerBurst"] = "",
        ["BurstInterShotInterval"] = "StatDesc_ReloadTime_postfix",
        ["ReloadSingle"] = "",
        ["BonusAttackRange"] = "StatDesc_WeaponRangeFalloffMax_postfix",
        ["SustainedDPS"] = "DPS_postfix",
        ["RoundsPerSecondAtMaxSpin"] = "",
        ["CritDamageBonusPercent"]  = "StatDesc_CritDamageBonusScale_postfix",
["CritDamageReceivedPercent"] = "StatDesc_CritDamageReceivedScale_postfix",
        ["BulletLifesteal"] = "BulletLifestealPercentHero_postfix",
        ["GroundDashSpeed"] = "DashSpeed_postfix"
    }
    for _, category in ipairs(attribute_orders["category_order"]) do
        local category_attrs = attributes_data[category]
        local category_rgb  = category_data[category]["rgb"]
        if stats_to_include[category] ~= nil then
            for _, attr_key in ipairs(stats_to_include[category]) do
                local attr_data = category_attrs[attr_key]
               
                -- Fallback: if the stat is missing from this category, check all other categories
                if attr_data == nil then
                    for _, fallback_cat_data in pairs(attributes_data) do
                        if fallback_cat_data[attr_key] ~= nil then
                            attr_data = fallback_cat_data[attr_key]
                            break
                        end
                    end
                end


                local attr_localized, postfix
Array.forEach(tabArgs, function(tabData, tabIndex)
                if attr_data ~= nil then
build(contents, 'div', tabData.content, 'content' .. tabIndex, tabData.this)
                    attr_localized = lang_module.get_string(attr_data["label"])
end)
                    if attr_localized == nil or attr_localized == "" then
                        attr_localized = util_module.add_space_before_cap(attr_key)
                    end
                    postfix = lang_module.get_string(attr_data["postfix"])
                    if postfix == nil or postfix == "" then postfix = ""
                    else postfix = " (" .. postfix .. ")" end
                    if attr_key == "BulletRadius" then postfix = " (cm)" end
                else
                    attr_localized = dictionary_module.translate(attr_key)
                    postfix = lang_module.get_string(postfix_key_map[attr_key])
                    if postfix == nil then return "attr_key " .. attr_key .. " must be added to postfix_key_map" end
                    if postfix ~= "" then postfix = " (" .. postfix .. ")" end
                end
                local th_style = 'position: sticky; top: -1px; z-index: 3; background-color: rgb(' .. category_rgb .. ');'
                if stats_with_both_scaling[attr_key] then
                    th_style = th_style .. ' min-width: 130px;'
                elseif stats_with_any_scaling[attr_key] then
                    th_style = th_style .. ' min-width: 75px;'
                end
                headers_str = headers_str .. '<th style="' .. th_style .. '">' .. attr_localized .. postfix .. "</th>"
            end
        end
    end
    headers_str = "<tr>" .. headers_str .. "</tr>"


    return string.format(
return mw.html.create('div')
        '<div id="hero-comparison-container" data-max-power="%s" data-max-spirit="%s">' ..
:addClass('tabs-dynamic navigation-not-searchable')
        '<div style="overflow: auto; max-height: 70vh; width: 100%%;">' ..
:attr('data-nosnippet')
        '<table class="wikitable sortable" style="table-layout: auto; width: 100%%;" id="hero-comparison-table">%s%s</table>' ..
:node(tabs)
        '</div></div>',
:newline()
        max_power, max_spirit, headers_str, body_str
:node(contents)
    )
end
end


return p
return Tabs
Please note that all contributions to The Deadlock Wiki are considered to be released under the Creative Commons Attribution-NonCommercial-ShareAlike (see Deadlock:Copyrights for details). If you do not want your writing to be edited mercilessly and redistributed at will, then do not submit it here.
You are also promising us that you wrote this yourself, or copied it from a public domain or similar free resource. Do not submit copyrighted work without permission!
Cancel Editing help (opens in new window)
Preview page with this template

Page included on this page: