Module:Sandbox/LVL: Difference between revisionsGive feedback
No edit summary |
No edit summary |
||
| (29 intermediate revisions by the same user not shown) | |||
| Line 30: | Line 30: | ||
end | end | ||
-- {{ | -- ========================================================================= | ||
-- DPS calculation helpers | |||
-- ========================================================================= | |||
local function calculate_dps(stats, dps_type) | |||
local rps = stats.RoundsPerSecond or 0 | |||
if rps == 0 then return 0 end | |||
local bullets_per_shot = (stats.HitOnceAcrossAllBullets and 1) or (stats.BulletsPerShot or 1) | |||
local burst_count = stats.BulletsPerBurst or 1 | |||
local cycle_time = 1 / rps | |||
local total_cycle_time = cycle_time * burst_count | |||
if total_cycle_time == 0 then return 0 end | |||
local base_damage = stats.BulletDamage or 0 | |||
if dps_type == 'burst' then | |||
return base_damage * bullets_per_shot * burst_count / total_cycle_time | |||
end | |||
local clip_size = stats.ClipSize or 0 | |||
if clip_size <= 0 then | |||
return base_damage * bullets_per_shot * burst_count / total_cycle_time | |||
end | |||
local reload_time | |||
if stats.ReloadSingle then | |||
reload_time = (stats.ReloadTime or 0) * clip_size | |||
else | |||
reload_time = stats.ReloadTime or 0 | |||
end | |||
reload_time = reload_time + (stats.ReloadDelay or 0) | |||
local time_to_empty_clip = (clip_size / burst_count) * total_cycle_time | |||
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 | |||
-- Compute how much DPS changes when all relevant component scalings are applied. | |||
-- Used for alt-fire rows where explicit DPS/SustainedDPS scaling keys are missing. | |||
local function compute_dps_scaling(hero_data, base_stats, dps_type, scaling_type) | |||
local scaling_source = hero_data[scaling_type .. "Scaling"] or {} | |||
local component_scalings = {} | |||
local possible_keys = { | |||
"BulletDamage", "RoundsPerSecond", "ClipSize", "ReloadTime", | |||
"ReloadDelay", "BulletsPerShot", "BulletsPerBurst" | |||
} | |||
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 | |||
if base_stats.ClipSize and not component_scalings.ClipSize then | |||
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 | |||
return 0 | |||
end | |||
local base_dps = calculate_dps(base_stats, dps_type) | |||
local scaled_stats = {} | |||
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 | |||
-- ========================================================================= | |||
-- Main module function | |||
-- ========================================================================= | |||
p.write_hero_comparison_table = function(frame) | p.write_hero_comparison_table = function(frame) | ||
local power_increases = tonumber(frame.args[1]) or 0 | local power_increases = tonumber(frame.args[1]) or 0 | ||
| Line 39: | Line 123: | ||
local display_scaling_icons = (power_increases == 0 and spirit_power == 0) | local display_scaling_icons = (power_increases == 0 and spirit_power == 0) | ||
local body_str | local body_str = "" | ||
local stats_to_include = { | local stats_to_include = { | ||
Weapon = { | Weapon = { | ||
"DPS", | "DPS", "SustainedDPS", "BulletDamage", "RoundsPerSecond", "FireRate", | ||
"ClipSize", "ReloadTime", "ReloadDelay", "ReloadSingle", "BulletsPerShot", "BulletsPerBurst", | |||
"BurstInterShotInterval", "LightMeleeDamage", "HeavyMeleeDamage", | |||
"BulletSpeed", "FalloffStartRange", "FalloffEndRange", | |||
"CritDamageBonusPercent", "BulletRadius", "RoundsPerSecondAtMaxSpin", "SpinAcceleration", "SpinDeceleration" | |||
"ClipSize", | |||
"BurstInterShotInterval", | |||
"BulletSpeed", | |||
"CritDamageBonusPercent", | |||
}, | }, | ||
Vitality = { | Vitality = { | ||
"MaxHealth", | "MaxHealth", "BaseHealthRegen", "BulletResist", "TechResist", "MeleeResist", | ||
"CritDamageReceivedPercent", "DebuffResist", "BulletLifesteal", "MaxMoveSpeed", | |||
"SprintSpeed", "StaminaCooldown", "Stamina", "GroundDashSpeed", "GravityChange" | |||
"CritDamageReceivedPercent", | |||
"SprintSpeed", | |||
}, | }, | ||
Spirit = { | Spirit = { "TechPower" } | ||
} | } | ||
-- Collect and sort heroes alphabetically, | -- Collect and sort heroes alphabetically, excluding disabled/in-development | ||
local sorted_heroes = {} | local sorted_heroes = {} | ||
for hero_key, hero_data in pairs(heroes_data) do | for hero_key, hero_data in pairs(heroes_data) do | ||
| Line 104: | Line 155: | ||
end) | end) | ||
-- | -- Build a single stat cell (<td>) with data attributes for JS recalculation | ||
local function | local function buildStatCell(hero_data, hero_key, attr_key, is_alt_fire, category) | ||
local | -- Conversion factors for stats that need unit changes (e.g. metres → centimetres) | ||
local unit_conversion = { | |||
BulletRadius = 100 -- metres to centimetres | |||
local | } | ||
local conv_factor = unit_conversion[attr_key] or 1 | |||
local | |||
local base_value | |||
if is_alt_fire then | if is_alt_fire then | ||
if attr_key == "ClipSize" or attr_key == "ReloadTime" then | |||
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 | |||
if type(base_value) == "number" then | |||
base_value = base_value * conv_factor | |||
end | end | ||
local stat_value = base_value | |||
if | |||
-- Scaling lookup | |||
local scaling_data | |||
local | if is_alt_fire then | ||
if attr_key == "ClipSize" or attr_key == "ReloadTime" then | |||
scaling_data = hero_data_module.get_hero_scaling_data(hero_data, attr_key) | |||
elseif (attr_key == "DPS" or attr_key == "SustainedDPS") | |||
and hero_data.Weapon and hero_data.Weapon.AltFire then | |||
-- 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 = "" | |||
local spirit_scale = 0 | |||
local level_scale = 0 | |||
local spirit_val = 0 | |||
local level_val = 0 | |||
if scaling_data ~= nil then | |||
for scaling_val, scaling_type in pairs(scaling_data) do | |||
if scaling_type == "Spirit" then | |||
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 | |||
local function append_scaling(val, stype) | |||
if val ~= 0 then | |||
local scaling_str = hero_data_module.write_scalar_str(val, stype, true) | |||
if scaling_str ~= "" then | |||
scaling_strs = scaling_strs .. " " .. scaling_str | |||
end | end | ||
end | |||
end | |||
append_scaling(spirit_val, "Spirit") | |||
append_scaling(level_val, "Level") | |||
if type(stat_value) == "number" then | |||
stat_value = stat_value + (spirit_power * spirit_val) | |||
stat_value = stat_value + (power_increases * level_val) | |||
end | |||
spirit_scale = spirit_val | |||
level_scale = level_val | |||
end | |||
if attr_key == "TechPower" and spirit_scale == 0 then | |||
spirit_scale = 1.0 | |||
if type(stat_value) == "number" then | |||
stat_value = stat_value + (spirit_power * spirit_scale) | |||
end | |||
end | |||
local innate_spirit_scale = | |||
(hero_data["LevelScaling"] and hero_data["LevelScaling"]["TechPower"]) or 0 | |||
if not display_scaling_icons then scaling_strs = "" end | |||
if type(stat_value) == "boolean" then | |||
stat_value = tostring(stat_value) | |||
else | |||
stat_value = util_module.round_to_sig_fig(stat_value, 5) | |||
end | |||
local cell_inner = string.format( | |||
'<span class="stat-num">%s</span><span class="stat-scaling">%s</span>', | |||
stat_value, | |||
scaling_strs | |||
) | |||
local weapon_table = hero_data.Weapon | |||
if is_alt_fire and weapon_table and weapon_table.AltFire then | |||
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( | |||
'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"', | |||
attr_key, | |||
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) | |||
end | |||
-- Generate a hero's primary stat row | |||
local function generatePrimaryRow(hero_data, hero_key, has_alt) | |||
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 | end | ||
end | end | ||
return "<tr>" .. row_str .. "</tr>" | |||
end | |||
-- Generate an expandable alt-fire sub-row (weapon stats only) | |||
local function generateAltFireSubRow(hero_data, hero_key) | |||
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 .. ' <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 | end | ||
-- Build | -- Build all body rows | ||
for _, hero_entry in ipairs(sorted_heroes) do | for _, hero_entry in ipairs(sorted_heroes) do | ||
local hero_key = hero_entry.key | local hero_key = hero_entry.key | ||
local hero_data = hero_entry.data | local hero_data = hero_entry.data | ||
local has_alt = hero_data.Weapon and hero_data.Weapon.AltFire and true or false | |||
body_str = body_str .. | body_str = body_str .. generatePrimaryRow(hero_data, hero_key, has_alt) | ||
if | if has_alt then | ||
body_str = body_str .. | body_str = body_str .. generateAltFireSubRow(hero_data, hero_key) | ||
end | end | ||
end | end | ||
-- Pre-pass: determine which stats have any scaling, and which have both types | -- Pre-pass: determine which stats have any scaling, and which have both types. | ||
-- | -- Used to set column widths in the header. | ||
local stats_with_any_scaling = {} | local stats_with_any_scaling = {} | ||
local stats_with_both_scaling = {} | local stats_with_both_scaling = {} | ||
| Line 240: | Line 373: | ||
if scaling_data ~= nil and next(scaling_data) ~= nil then | if scaling_data ~= nil and next(scaling_data) ~= nil then | ||
stats_with_any_scaling[attr_key] = true | stats_with_any_scaling[attr_key] = true | ||
local has_spirit = false | local has_spirit, has_level = false, false | ||
for _, scaling_type in pairs(scaling_data) do | for _, scaling_type in pairs(scaling_data) do | ||
if scaling_type == "Spirit" then has_spirit = true | if scaling_type == "Spirit" then has_spirit = true | ||
elseif scaling_type == "Level" then has_level = true end | |||
end | end | ||
if has_spirit and has_level then stats_with_both_scaling[attr_key] = true end | |||
end | end | ||
-- | -- Alt-fire scaling (only for stats present in the AltFire table) | ||
if hero_data.Weapon | if hero_data.Weapon | ||
and hero_data.Weapon.AltFire | and hero_data.Weapon.AltFire | ||
and hero_data.Weapon.AltFire[attr_key] ~= nil | and hero_data.Weapon.AltFire[attr_key] ~= nil | ||
then | then | ||
local alt_scaling = hero_data_module.get_hero_scaling_data(hero_data, attr_key .. "AltFire") | 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 | |||
stats_with_both_scaling[attr_key] = true | 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 | ||
| Line 275: | Line 419: | ||
end | end | ||
-- Build header row | -- Build table header row | ||
local headers_str = '<th style="position: sticky; left: 0; top: -1px; z-index: 12; background-color: | 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>' | ||
local category_data = attribute_module.get_category_data() | local category_data = attribute_module.get_category_data() | ||
local postfix_key_map = { | local postfix_key_map = { | ||
["ReloadDelay"] | ["ReloadDelay"] = "StatDesc_ReloadTime_postfix", | ||
["BulletsPerShot"] | ["BulletsPerShot"] = "", | ||
["BulletsPerBurst"] | ["BulletsPerBurst"] = "", | ||
["BurstInterShotInterval"] | ["BurstInterShotInterval"] = "StatDesc_ReloadTime_postfix", | ||
["ReloadSingle"] | ["ReloadSingle"] = "", | ||
["BonusAttackRange"] | ["BonusAttackRange"] = "StatDesc_WeaponRangeFalloffMax_postfix", | ||
["SustainedDPS"] | ["SustainedDPS"] = "DPS_postfix", | ||
["RoundsPerSecondAtMaxSpin"] = "", | ["RoundsPerSecondAtMaxSpin"] = "", | ||
[" | ["CritDamageBonusPercent"] = "StatDesc_CritDamageBonusScale_postfix", | ||
["CritDamageReceivedPercent"] = "StatDesc_CritDamageReceivedScale_postfix", | |||
["BulletLifesteal"] | ["BulletLifesteal"] = "BulletLifestealPercentHero_postfix", | ||
["GroundDashSpeed"] | ["GroundDashSpeed"] = "DashSpeed_postfix" | ||
} | } | ||
for _, category in ipairs(attribute_orders["category_order"]) do | for _, category in ipairs(attribute_orders["category_order"]) do | ||
local category_attrs = attributes_data[category] | local category_attrs = attributes_data[category] | ||
local category_rgb = category_data[category]["rgb"] | local category_rgb = category_data[category]["rgb"] | ||
if stats_to_include[category] ~= nil then | if stats_to_include[category] ~= nil then | ||
for _, attr_key in ipairs(stats_to_include[category]) do | for _, attr_key in ipairs(stats_to_include[category]) do | ||
local attr_data = category_attrs[attr_key] | 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 | |||
if attr_data ~= nil then | if attr_data ~= nil then | ||
attr_localized = lang_module.get_string(attr_data["label"]) | attr_localized = lang_module.get_string(attr_data["label"]) | ||
| Line 309: | Line 460: | ||
end | end | ||
postfix = lang_module.get_string(attr_data["postfix"]) | postfix = lang_module.get_string(attr_data["postfix"]) | ||
if postfix == nil or postfix == "" then | if postfix == nil or postfix == "" then postfix = "" | ||
else postfix = " (" .. postfix .. ")" end | |||
else | if attr_key == "BulletRadius" then postfix = " (cm)" end | ||
end | |||
else | else | ||
attr_localized = dictionary_module.translate(attr_key) | attr_localized = dictionary_module.translate(attr_key) | ||
postfix = lang_module.get_string(postfix_key_map[attr_key]) | postfix = lang_module.get_string(postfix_key_map[attr_key]) | ||
if postfix == nil then | if postfix == nil then return "attr_key " .. attr_key .. " must be added to postfix_key_map" end | ||
if postfix ~= "" then postfix = " (" .. postfix .. ")" end | |||
if postfix ~= "" then | |||
end | end | ||
local th_style = 'position: sticky; top: -1px; z-index: 3; background-color: rgb(' .. category_rgb .. ');' | local th_style = 'position: sticky; top: -1px; z-index: 3; background-color: rgb(' .. category_rgb .. ');' | ||
if stats_with_both_scaling[attr_key] then | if stats_with_both_scaling[attr_key] then | ||
| Line 331: | Line 475: | ||
th_style = th_style .. ' min-width: 75px;' | th_style = th_style .. ' min-width: 75px;' | ||
end | end | ||
headers_str = headers_str .. '<th style="' .. th_style .. '">' .. attr_localized .. postfix .. "</th>" | |||
headers_str = headers_str .. | |||
end | end | ||
end | end | ||
| Line 344: | Line 486: | ||
'<table class="wikitable sortable" style="table-layout: auto; width: 100%%;" id="hero-comparison-table">%s%s</table>' .. | '<table class="wikitable sortable" style="table-layout: auto; width: 100%%;" id="hero-comparison-table">%s%s</table>' .. | ||
'</div></div>', | '</div></div>', | ||
max_power, | max_power, max_spirit, headers_str, body_str | ||
) | ) | ||
end | end | ||
return p | return p | ||