Module:StatScaling

Revision as of 15:22, 11 August 2026 by Vergir (talk | contribs) (Show Fire Rate's base as 0%, matching the resistances (with help from vergir-bot LLM))
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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

-- Builds tables of hero base stats that scale with a given scaling type,
-- read from [[Data:HeroData.json]].
--
-- Each public function below renders one table. The shared work lives in
-- build_scaling_table(), so adding a table for another scaling type (Boons,
-- for instance) only takes a new wrapper naming its scaling type.
--
-- {{#invoke:StatScaling|write_spirit_scaling_table}}
local p = {}

local heroes_data       = mw.loadJsonData("Data:HeroData.json")
local attributes_data   = mw.loadJsonData("Data:AttributeData.json")
local util_module       = require('Module:Utilities')
local lang_module       = require('Module:Lang')
local dictionary_module = require('Module:Dictionary')
local hero_data_module  = require('Module:HeroData')

-- Never listed: both are derived from the weapon stats that are already
-- listed in their own right, so they only ever duplicate a row.
local EXCLUDED_STATS = {
	DPS = true,
	SustainedDPS = true,
}

-- Per stat handling of the base value shown before the scaling.
--   text:     printed verbatim, for stats the data holds no base value for
--   sig_figs: rounds an over-precise base value
local BASE_DISPLAY = {
	BulletResist    = {text = "0%"},
	TechResist      = {text = "0%"},
	FireRate        = {text = "0%"},
	RoundsPerSecond = {sig_figs = 3},
}

--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------

-- next() ignores metamethods and mw.loadJsonData hands back proxy tables, so
-- keys always have to be collected through pairs() rather than tested with next().
local function sorted_keys(tbl)
	local keys = {}
	if (tbl == nil) then return keys end
	for key in pairs(tbl) do table.insert(keys, key) end
	table.sort(keys)
	return keys
end

-- Localized name of a stat, resolved in three steps:
--   1. [[Data:AttributeData.json]] label -> Valve's own translation
--   2. [[Data:Dictionary]] entry, for stats Valve has no label for
--   3. the raw key, spaced out, with a missing-translation tooltip
local function stat_label(stat)
	for _, category in pairs(attributes_data) do
		local attribute = category[stat]
		if (attribute ~= nil and attribute["label"] ~= nil) then
			local localized = lang_module.get_string(attribute["label"])
			if (localized ~= nil and localized ~= '') then return localized end
		end
	end

	local translated = dictionary_module.translate(stat)
	if (translated ~= nil and translated:sub(1, 5) ~= "Key '") then return translated end

	return util_module.add_space_before_cap(stat) ..
		mw.getCurrentFrame():expandTemplate{title = "MissingValveTranslationTooltip"}
end

-- Heroes are listed as they are read, so "The Doorman" sorts under D.
local function sort_name(hero_data, hero_key)
	return (hero_data["Name"] or hero_key):gsub("^The ", "")
end

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

-- Base value as displayed, or nil when the stat has none to show.
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)
	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

-- "6.3 + x0.0084", or the scaling alone when there is no base value to show.
local function scaling_cell(hero_data, stat, value, scaling_type)
	local scaling_str = hero_data_module.write_scalar_str(value, scaling_type)
	local base = base_value(hero_data, stat)
	if (base == nil) then return scaling_str end
	return base .. ' + ' .. scaling_str
end

-- Every scaling a hero has of one type, minus the excluded stats and any
-- zeroes, ordered by localized stat name:
--   { { stat = , label = , value = }, ... }
local function hero_scalings(hero_data, scaling_key)
	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

--------------------------------------------------------------------------------
-- Shared table builder
--------------------------------------------------------------------------------

-- scaling_type: "Spirit" or "Level", picking both the <type>Scaling data key
-- and the scaling template ({{Ss}} or {{PI}}).
local function build_scaling_table(scaling_type)
	local scaling_key = scaling_type .. "Scaling"

	-- Collect the heroes that have anything to show
	local rows = {}
	for _, hero_key in ipairs(sorted_keys(heroes_data)) do
		local hero_data = heroes_data[hero_key]
		if (not hero_data["InDevelopment"] and not hero_data["IsDisabled"]) then
			local scalings = hero_scalings(hero_data, scaling_key)
			if (#scalings > 0) then
				table.insert(rows, {
					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 = {
		'{| class="wikitable sortable"',
		'! ' .. dictionary_module.translate("Hero"),
		'! ' .. dictionary_module.translate("Stats"),
		'! ' .. dictionary_module.translate("Scaling"),
	}
	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')
end

--------------------------------------------------------------------------------
-- Wikitext entry points
--------------------------------------------------------------------------------

-- Hero base stats that scale with Spirit Power.
--{{#invoke:StatScaling|write_spirit_scaling_table}}
function p.write_spirit_scaling_table(frame)
	return build_scaling_table("Spirit")
end

return p