Module:Buildup

Revision as of 16:45, 7 February 2026 by LVL (talk | contribs) (Fix for Silver's transformed state. She seems to use her normal form buildup numbers.)

Overview

The Buildup module provides functions for calculating the Status Effect Buildup per shot for heroes.

This is used for items that apply a status effect based on filling a meter (e.g., Toxic Bullets, Silencer, Inhibitor). The calculation normalizes the buildup based on the hero's Fire Rate (RPS) to ensure the time-to-activate is roughly consistent across different weapon types.

It references Data:HeroData.json for weapon stats (RPS, Spin-up) and Data:ItemData.json for the item's base buildup value.

Functions

main

The primary entry point used by Template:Buildup. It automatically detects whether to generate a full table or a single value based on the number of parameters provided.

get_bps

Calculates the specific buildup percentage per shot for a single hero.

Parameters

  • hero_name - Name of the hero in English (e.g., "Abrams") or the hero key.
  • item_name - Name of the item (e.g., "Toxic Bullets").

Example

{{#invoke:Buildup|get_bps|Haze|Toxic Bullets}}

Returns: 7.4

write_buildup_table

Generates a sortable wikitable listing the buildup percentage and shots-to-proc for every valid hero.

Parameters

  • item_name - Name of the item (e.g., "Silencer").

Example

{{#invoke:Buildup|write_buildup_table|Silencer}}

Internal Logic

The module uses the following formula derived from the game's mechanics: BPS=100BaseBuildup×(RPSeffective+1)

The Effective RPS is determined by analyzing specific hero mechanics:

  • Standard Heroes: Uses the standard RoundsPerSecond from the data files.
  • Spin-Up Heroes: For heroes like McGinnis, the calculation uses their Max Spin RPS.
  • Burst/Hybrid Heroes: For heroes like Paige and The Doorman, the game displays a Fire Rate that includes an "Intra-Burst" delay, but the Buildup mechanic ignores this delay. The module detects this configuration (Burst Count = 1, but Interval > 0) and mathematically removes the interval to determine the true Effective RPS.
  • Shots to Proc: The table uses math.ceil to always round up, ensuring accuracy for cases like Kelvin where the math results in 19.99% (requiring 6 shots, not 5).

local p = {}

local heroes_data = mw.loadJsonData("Data:HeroData.json")
local items_data = mw.loadJsonData("Data:ItemData.json")

-- Helper to find Item Buildup value by name
-- Handles both flat numbers and lookup in the ItemData file
local function get_item_bb(target_name)
	if target_name == nil then return nil end
	
	-- Clean input
	local clean_name = mw.text.trim(target_name)
	
	-- If user input a number directly
	if tonumber(clean_name) then return tonumber(clean_name) end

	-- Iterate Item Data
	for key, item in pairs(items_data) do
		if item["Name"] == clean_name then
			
			-- Valve data structure (nested)
			if item["m_mapAbilityProperties"] and item["m_mapAbilityProperties"]["BuildUpPerShot"] then
				return tonumber(item["m_mapAbilityProperties"]["BuildUpPerShot"]["m_strValue"])
			end
			
			-- Flat data structure (fallback)
			if item["BuildUpPerShot"] then
				return tonumber(item["BuildUpPerShot"])
			end
		end
	end
	return nil
end

-- Helper to find Hero Data by name or key
local function get_hero_data(hero_name)
	if hero_name == nil then return nil end
	
	local clean_name = mw.text.trim(hero_name)
	
	-- Try direct Key lookup
	if heroes_data[clean_name] then return heroes_data[clean_name] end
	
	-- Try adding "hero_" prefix
	local key_lower = "hero_" .. string.lower(clean_name)
	if heroes_data[key_lower] then return heroes_data[key_lower] end
	
	-- Search by Name field
	for k, v in pairs(heroes_data) do
		if v["Name"] == clean_name then return v end
	end
	return nil
end

-- Core calculation logic
local function calculate_bps(hero, bb)
    if not hero or not hero.Weapon then return 0 end

    local rps = tonumber(hero.Weapon.RoundsPerSecond or 0)
    local spin_rps = tonumber(hero.Weapon.RoundsPerSecondAtMaxSpin or 0)
    
    -- 1. Handle Spin-up (McGinnis/Bebop)
    if spin_rps > 0 then 
        rps = spin_rps 
    end

    -- 2. Handle "Fake Burst" Logic (Paige/Doorman)
    -- If they have a burst interval, but only 1 bullet per burst,
    -- the game ignores that interval for Buildup calculation.
    local burst_count = tonumber(hero.Weapon.BulletsPerBurst or 1)
    local burst_interval = tonumber(hero.Weapon.BurstInterShotInterval or 0)

    if burst_count == 1 and burst_interval > 0 and rps > 0 then
        -- Convert RPS back to time
        local total_time = 1 / rps
        -- Remove the interval
        local effective_time = total_time - burst_interval
        
        -- Prevent division by zero if something is weird
        if effective_time > 0.001 then
            rps = 1 / effective_time
        end
    end

    -- 3. Handle Silver (Transformed) - uses base form's RPS for buildup
    if hero.Name == "Silver (Transformed)" then
        rps = 1.1111  -- Base Silver's RoundsPerSecond
    end

    if rps == 0 then return 0 end

    -- Formula: 100 / (BB * (RPS + 1))
    return 100 / (bb * (rps + 1))
end

--{{#invoke:Buildup|get_bps|HERO_NAME|ITEM_NAME}}
--Returns a single float value (rounded to 1 decimal)
p.get_bps = function(frame)
	local hero_name = frame.args[1] or frame.args['hero']
	local item_name = frame.args[2] or frame.args['item']

	local hero = get_hero_data(hero_name)
	if not hero then return "Hero Not Found" end

	local bb = get_item_bb(item_name)
	if not bb then return "Item Not Found" end

	local result = calculate_bps(hero, bb)
	return string.format("%.1f", result)
end

--{{#invoke:Buildup|write_buildup_table|ITEM_NAME}}
--Generates a sortable wikitable for all heroes
p.write_buildup_table = function(frame)
    local item_name = frame.args[1]
    
    local bb = get_item_bb(item_name)
    if not bb then return "Error: Item '" .. (item_name or "nil") .. "' not found." end

    local hero_list = {}
    
    for key, hero in pairs(heroes_data) do
        local is_dev = hero["InDevelopment"] == true
        local is_disabled = hero["IsDisabled"] == true
        
        if not is_dev and not is_disabled and hero["Weapon"] and hero["Name"] then
            local val = calculate_bps(hero, bb)
            
            if val > 0 then
                local shots = math.ceil(100 / val)
                
                table.insert(hero_list, {
                    name = hero["Name"],
                    bps = val,
                    shots = shots
                })
            end
        end
    end

    -- Sorting Logic
    table.sort(hero_list, function(a, b) 
        -- Helper to strip "The " from names just for sorting
        local function clean_sort_name(name)
            if string.sub(name, 1, 4) == "The " then
                return string.sub(name, 5)
            end
            return name
        end

        return clean_sort_name(a.name) < clean_sort_name(b.name) 
    end)

    -- Build Table
    local html = mw.html.create('table')
    html:addClass('wikitable sortable mw-collapsible')
    
    if item_name then
        html:tag('caption'):wikitext('Buildup Per Shot: ' .. item_name)
    end

    -- Headers
    local headerRow = html:tag('tr')
    headerRow:tag('th'):wikitext('Hero')
    headerRow:tag('th'):wikitext('% per shot')
    headerRow:tag('th'):wikitext('Shots to Proc')

    -- Rows
    for _, h in ipairs(hero_list) do
        local row = html:tag('tr')
        local icon_template = frame:expandTemplate{ title = 'HeroIcon', args = { h.name } }
        
        -- Use data-sort-value so the table sorts correctly even with the icon
        row:tag('td')
            :attr('data-sort-value', h.name) -- Uses the name including "The" for consistent default sort
            :wikitext(icon_template)
            
        row:tag('td'):wikitext(string.format("%.1f", h.bps))
        row:tag('td'):wikitext(tostring(h.shots))
    end

    return tostring(html)
end

-- Main entry point for the template
-- Decides whether to return a single value or a full table based on arguments
p.main = function(frame)
	local args = frame:getParent().args
	-- Handle direct #invoke calls vs Template calls
	if not args[1] and not args['item'] then args = frame.args end

	local arg1 = args[1] or args['hero']
	local arg2 = args[2] or args['item']

	-- If both Hero and Item are present -> Return Single Number
	if arg1 and arg2 then
		-- Manually setting args for the helper function to read
		frame.args = {arg1, arg2}
		return p.get_bps(frame)
		
	-- If only one arg (Item) is present -> Return Table
	elseif arg1 then
		frame.args = {arg1}
		return p.write_buildup_table(frame)
		
	else
		return "Error: Provide [Item Name] for a table, or [Hero Name] [Item Name] for a value."
	end
end

return p