Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Join the Playtest on Steam Now: SpiritVale

Module:GameSkills: Difference between revisions

From SpiritVale Wiki
No edit summary
No edit summary
Line 94: Line 94:
end
end
return table.concat(list, sep or ", ")
return table.concat(list, sep or ", ")
end
local function isNoneLike(v)
if v == nil then return true end
local s = mw.text.trim(tostring(v))
if s == "" then return true end
s = mw.ustring.lower(s)
return (s == "none" or s == "no" or s == "n/a" or s == "na" or s == "null")
end
end


Line 190: Line 198:
end
end
return true
return true
end
local function isZeroish(v)
if v == nil then return true end
if type(v) == "number" then return v == 0 end
if type(v) == "table" and v.Value ~= nil then
return isZeroish(v.Value)
end
local s = mw.text.trim(tostring(v))
if s == "" then return true end
-- common “0” shapes once wikiprep has stringified
if s == "0" or s == "0.0" or s == "0.00" then return true end
if s == "0s" or s == "0 s" then return true end
if s == "0m" or s == "0 m" then return true end
if s == "0%" or s == "0 %" then return true end
-- numeric-with-unit like "0.0000s"
local n = tonumber((mw.ustring.gsub(s, "[^0-9%.%-]", "")))
return (n ~= nil and n == 0)
end
end


Line 277: Line 303:


return baseText or perText
return baseText or perText
end
-- Prefer “value only” (dynSpan when series list exists)
local function valuePairDynamicValueOnly(block, maxLevel, level)
if type(block) ~= "table" then
return nil
end
local base = block.Base
local per  = block["Per Level"]
if type(per) == "table" then
if #per == 0 then
local baseText = formatUnitValue(base)
return baseText and mw.text.nowiki(baseText) or nil
end
if isFlatList(per) then
local one  = formatUnitValue(per[1]) or tostring(per[1])
local show = formatUnitValue(base) or one
return show and mw.text.nowiki(show) or nil
end
local series = {}
for _, v in ipairs(per) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
return dynSpan(series, level)
end
local txt = valuePairRawText(block)
return txt and mw.text.nowiki(txt) or nil
end
end


Line 337: Line 395:


return "Damage"
return "Damage"
end
local function valuePairDynamicValueOnly(block, maxLevel, level)
if type(block) ~= "table" then
return nil
end
local base = block.Base
local per  = block["Per Level"]
if type(per) == "table" then
if #per == 0 then
local baseText = formatUnitValue(base)
return baseText and mw.text.nowiki(baseText) or nil
end
if isFlatList(per) then
local one  = formatUnitValue(per[1]) or tostring(per[1])
local show = formatUnitValue(base) or one
return show and mw.text.nowiki(show) or nil
end
local series = {}
for _, v in ipairs(per) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
return dynSpan(series, level)
end
local txt = valuePairRawText(block)
return txt and mw.text.nowiki(txt) or nil
end
-- Source (new unified Damage/Flat/Reflect/Healing) formatter (for infobox rows)
local function formatSource(src, maxLevel, level)
if type(src) ~= "table" then
return nil
end
local kind = src.Type or "Damage"
local isHealing = (kind == "Healing") or (src.Healing == true)
local basis = basisLabel(src, isHealing)
if basis and mw.ustring.lower(tostring(basis)) == mw.ustring.lower(tostring(kind)) then
basis = nil
end
local val = valuePairDynamicValueOnly(src, maxLevel, level)
if not val then
return nil
end
local out = mw.text.nowiki(tostring(kind)) .. ": " .. val
if basis then
out = out .. " " .. mw.text.nowiki(tostring(basis))
end
return out
end
end


Line 466: Line 466:
end
end
end
end
end
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
-- Scaling supports either a single dict or a list (for infobox rows)
local function formatScaling(scaling, basisOverride)
if type(scaling) ~= "table" then
return nil
end
local list = scaling
if #list == 0 then
if scaling.Percent ~= nil or scaling["Scaling ID"] or scaling["Scaling Name"] then
list = { scaling }
else
return nil
end
end
local parts = {}
for _, s in ipairs(list) do
if type(s) == "table" then
local stat = s["Scaling Name"] or s["Scaling ID"] or "Unknown"
local pct  = s.Percent
local pctN = toNum(pct)
local basis = basisOverride or basisLabel(s, false)
if pctN ~= nil and pctN ~= 0 then
table.insert(parts, string.format("%s%% %s Per %s", fmtNum(pctN), basis, stat))
elseif pct ~= nil and tostring(pct) ~= "" and tostring(pct) ~= "0" then
table.insert(parts, string.format("%s%% %s Per %s", tostring(pct), basis, stat))
end
end
end
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
local function formatArea(area, maxLevel, level)
if type(area) ~= "table" then
return nil
end
local parts = {}
local distLine = valuePairDynamicText("Distance", area["Area Distance"], maxLevel, level, "<br />")
if distLine then
table.insert(parts, distLine)
end
local size = area["Area Size"]
if size and size ~= "" then
table.insert(parts, "Size: " .. mw.text.nowiki(tostring(size)))
end
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
local function formatTimingBlock(bt, maxLevel, level)
if type(bt) ~= "table" then
return nil
end
local parts = {}
local function add(label, key)
local block = bt[key]
if type(block) ~= "table" then
return
end
local lines = valuePairDynamicLines(label, block, maxLevel, level)
for _, line in ipairs(lines) do
table.insert(parts, line)
end
end
add("Cast Time", "Cast Time")
add("Cooldown",  "Cooldown")
add("Duration",  "Duration")
if bt["Effect Cast Time"] ~= nil then
table.insert(parts, "Effect Cast Time: " .. mw.text.nowiki(tostring(bt["Effect Cast Time"])))
end
if bt["Damage Delay"] ~= nil then
table.insert(parts, "Damage Delay: " .. mw.text.nowiki(tostring(bt["Damage Delay"])))
end
if bt["Effect Remove Delay"] ~= nil then
table.insert(parts, "Effect Remove Delay: " .. mw.text.nowiki(tostring(bt["Effect Remove Delay"])))
end
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
local function formatResourceCost(rc, maxLevel, level)
if type(rc) ~= "table" then
return nil
end
local parts = {}
local manaLines = valuePairDynamicLines("MP", rc["Mana Cost"], maxLevel, level)
for _, line in ipairs(manaLines) do
table.insert(parts, line)
end
local hpLines = valuePairDynamicLines("HP", rc["Health Cost"], maxLevel, level)
for _, line in ipairs(hpLines) do
table.insert(parts, line)
end
end


Line 697: Line 587:
end
end


local function formatStatusApplications(list, maxLevel, level)
local function formatStatusApplications(list, maxLevel, level, suppressDurationIndex)
if type(list) ~= "table" or #list == 0 then
if type(list) ~= "table" or #list == 0 then
return nil
return nil
Line 703: Line 593:


local parts = {}
local parts = {}
for _, s in ipairs(list) do
for idx, s in ipairs(list) do
if type(s) == "table" then
if type(s) == "table" then
local typ  = s.Type or s.Scope or "Target"
local typ  = s.Type or s.Scope or "Target"
Line 711: Line 601:
local detail = {}
local detail = {}


if type(s.Duration) == "table" then
-- Duration (optionally suppressed if Module 4 “promoted” it)
if idx ~= suppressDurationIndex and type(s.Duration) == "table" then
local t = valuePairDynamicText("Duration", s.Duration, maxLevel, level, "; ")
local t = valuePairDynamicText("Duration", s.Duration, maxLevel, level, "; ")
if t then table.insert(detail, t) end
if t then table.insert(detail, t) end
Line 878: Line 769:
end
end


-- Helper: render an intentionally-empty hero module slot (keeps the 2x2 grid stable)
local function buildEmptyModule(slot)
local function buildEmptyModule(slot)
return moduleBox(slot, nil, "", true)
return moduleBox(slot, nil, "", true)
Line 885: Line 775:
-- ------------------------------------------------------------
-- ------------------------------------------------------------
-- Module 1 – Level Selector
-- Module 1 – Level Selector
-- FIX: If maxLevel == 1, do NOT emit a range input (prevents JS edge-cases)
-- ------------------------------------------------------------
-- ------------------------------------------------------------
local function buildModuleLevelSelector(level, maxLevel)
local function buildModuleLevelSelector(level, maxLevel)
Line 910: Line 799:
:attr("aria-label", "Skill level select")
:attr("aria-label", "Skill level select")
else
else
-- single-level skills: keep layout but no <input>
inner:addClass("sv-level-ui-single")
inner:addClass("sv-level-ui-single")
slider:addClass("sv-level-slider-single")
slider:addClass("sv-level-slider-single")
Line 920: Line 808:
-- ------------------------------------------------------------
-- ------------------------------------------------------------
-- Module 2 – Skill Type
-- Module 2 – Skill Type
-- (If non-damaging, hide Damage + Element; keep Target + Cast)
-- ------------------------------------------------------------
-- ------------------------------------------------------------
local function buildModuleSkillType(typeBlock)
local function buildModuleSkillType(typeBlock, hideDamageAndElement)
typeBlock = (type(typeBlock) == "table") and typeBlock or {}
typeBlock = (type(typeBlock) == "table") and typeBlock or {}


Line 931: Line 820:
if x.Name and x.Name ~= "" then return tostring(x.Name) end
if x.Name and x.Name ~= "" then return tostring(x.Name) end
if x.ID and x.ID ~= "" then return tostring(x.ID) end
if x.ID and x.ID ~= "" then return tostring(x.ID) end
if x.Value ~= nil then return tostring(x.Value) end
end
end
if type(x) == "string" and x ~= "" then
if type(x) == "string" and x ~= "" then
Line 954: Line 844:
end
end


addChunk("Damage",  typeBlock.Damage  or typeBlock["Damage Type"])
if not hideDamageAndElement then
addChunk("Element", typeBlock.Element or typeBlock["Element Type"])
addChunk("Damage",  typeBlock.Damage  or typeBlock["Damage Type"])
addChunk("Element", typeBlock.Element or typeBlock["Element Type"])
end
 
addChunk("Target",  typeBlock.Target  or typeBlock["Target Type"])
addChunk("Target",  typeBlock.Target  or typeBlock["Target Type"])
addChunk("Cast",    typeBlock.Cast    or typeBlock["Cast Type"])
addChunk("Cast",    typeBlock.Cast    or typeBlock["Cast Type"])
Line 965: Line 858:
-- ============================================================
-- ============================================================
-- Module 3 – Skill Source (Modifier + Source + Scaling)
-- Module 3 – Skill Source (Modifier + Source + Scaling)
-- - Modifier column (optional): "Modifier" pill + Attack/Magic Attack/Hybrid
-- (unchanged from your current system)
-- - Source column: Kind pill + big % value
-- - Scaling column: "Scaling" pill + compact lines
--
-- RULE: If BOTH Source and Scaling are missing -> return nil (slot stays blank)
-- - Supports BOTH new (rec.Source) and legacy (rec.Damage) formats.
-- ============================================================
-- ============================================================


Line 1,016: Line 904:
end
end


-- Prefer the “current level value” (dynSpan when we have a series list)
local function sourceValueForLevel(src, maxLevel, level)
local function sourceValueForLevel(src, maxLevel, level)
if type(src) ~= "table" then
if type(src) ~= "table" then
Line 1,025: Line 912:
local per  = src["Per Level"]
local per  = src["Per Level"]


-- Preferred: expanded series list (wikiprep)
if type(per) == "table" and #per > 0 then
if type(per) == "table" and #per > 0 then
if isFlatList(per) then
if isFlatList(per) then
Line 1,040: Line 926:
end
end


-- Fallback: may show "X (Per Level: Y)" if not expanded
return valuePairDynamicValueOnly(src, maxLevel, level)
return valuePairDynamicValueOnly(src, maxLevel, level)
end
end


-- Legacy (pre-Source) damage entry: compute a simple % at the current level
local function legacyPercentAtLevel(entry, level)
local function legacyPercentAtLevel(entry, level)
if type(entry) ~= "table" then
if type(entry) ~= "table" then
Line 1,055: Line 939:
local perN    = toNum(perRaw)
local perN    = toNum(perRaw)


-- If Per Level exists, level 1 shows at least per*1 even when base is 0/missing.
if perN ~= nil and perN ~= 0 then
if perN ~= nil and perN ~= 0 then
local total = (baseN or 0) + (perN * level)
local total = (baseN or 0) + (perN * level)
Line 1,077: Line 960:
local scaling    = nil
local scaling    = nil


-- Preferred: new unified Source block
if type(rec.Source) == "table" then
if type(rec.Source) == "table" then
local src = rec.Source
local src = rec.Source
local atkFlag  = (src["ATK-Based"] == true)
local atkFlag  = (src["ATK-Based"] == true)
local matkFlag = (src["MATK-Based"] == true)
local matkFlag = (src["MATK-Based"] == true)
Line 1,090: Line 971:
end
end


-- Backcompat: legacy Damage block
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
local dmg = rec.Damage
local dmg = rec.Damage
Line 1,099: Line 979:
local flat = dmg["Flat Damage"]
local flat = dmg["Flat Damage"]


-- priority: Main Damage > Reflect > Flat
if type(main) == "table" and #main > 0 then
if type(main) == "table" and #main > 0 then
local pick = nil
local pick = nil
Line 1,141: Line 1,020:
local hasScaling  = (type(scalingLines) == "table" and #scalingLines > 0)
local hasScaling  = (type(scalingLines) == "table" and #scalingLines > 0)


-- RULE: if neither exists, leave module 3 blank
if (not hasSource) and (not hasScaling) then
if (not hasSource) and (not hasScaling) then
return nil
return nil
Line 1,148: Line 1,026:
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")


-- Classes drive grid layout in CSS
local extra = { "skill-source-module" }
local extra = { "skill-source-module" }
table.insert(extra, hasMod and "sv-has-mod" or "sv-no-mod")
table.insert(extra, hasMod and "sv-has-mod" or "sv-no-mod")
Line 1,161: Line 1,038:
wrap:addClass("sv-source-grid")
wrap:addClass("sv-source-grid")


-- Column 1: Modifier (optional)
if hasMod then
if hasMod then
local modCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-modifier")
local modCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-modifier")
modCol:tag("div")
modCol:tag("div"):addClass("sv-source-pill"):wikitext("Modifier")
:addClass("sv-source-pill")
modCol:tag("div"):addClass("sv-modifier-value"):wikitext(mw.text.nowiki(basisWord))
:wikitext("Modifier")
modCol:tag("div")
:addClass("sv-modifier-value")
:wikitext(mw.text.nowiki(basisWord))
end
end


-- Column 2: Source (optional)
if hasSource then
if hasSource then
local sourceCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-main")
local sourceCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-main")
sourceCol:tag("div")
sourceCol:tag("div"):addClass("sv-source-pill"):wikitext(mw.text.nowiki(sourceKind or "Source"))
:addClass("sv-source-pill")
sourceCol:tag("div"):addClass("sv-source-value"):wikitext(sourceVal)
:wikitext(mw.text.nowiki(sourceKind or "Source"))
sourceCol:tag("div")
:addClass("sv-source-value")
:wikitext(sourceVal)
end
end


-- Column 3: Scaling (optional)
if hasScaling then
if hasScaling then
local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling")
local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling")
scalingCol:tag("div")
scalingCol:tag("div"):addClass("sv-source-pill"):wikitext("Scaling")
:addClass("sv-source-pill")
:wikitext("Scaling")


local list = scalingCol:tag("div"):addClass("sv-scaling-list")
local list = scalingCol:tag("div"):addClass("sv-scaling-list")
for _, line in ipairs(scalingLines) do
for _, line in ipairs(scalingLines) do
list:tag("div")
list:tag("div"):addClass("sv-scaling-item"):wikitext(mw.text.nowiki(line))
:addClass("sv-scaling-item")
:wikitext(mw.text.nowiki(line))
end
end
end
end
Line 1,201: Line 1,063:
end
end


-- ------------------------------------------------------------
----------------------------------------------------------------------
-- Module 4 – Quick Stats (3x2 grid)
-- Module 4 – Quick Stats (3x2)
-- Range / Area / Cost / Cast Time / Cooldown / Duration
-- Range / Area / Cost / Cast Time / Cooldown / Duration
-- (Shows em dash when missing)
-- - Zero values =>
-- ------------------------------------------------------------
-- - Area “none/missing” => —
local function buildModuleQuickStats(rec, level, maxLevel)
-- - Cost: HP only shows if non-zero at some level; never show "0 HP"
local mech = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
-- - Special: if Duration missing AND skill is non-damaging, promote a status-application duration
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
----------------------------------------------------------------------
local rc  = (type(mech["Resource Cost"]) == "table") and mech["Resource Cost"] or {}


local function dash()
local function seriesFromValuePair(block, maxLevel)
return "—"
if type(block) ~= "table" then
return nil
end
end


-- Accept either a value-pair block (Base/Per Level) OR a unit-wrapped scalar OR a plain scalar.
local base = block.Base
local function renderAny(v)
local per  = block["Per Level"]
if type(v) == "table" and (v.Base ~= nil or v["Per Level"] ~= nil) then
 
return valuePairDynamicValueOnly(v, maxLevel, level)
local function pickUnit(v)
if type(v) == "table" and v.Unit and v.Unit ~= "" then
return v.Unit
end
end
return nil
end
local unit = pickUnit(base) or pickUnit(per)
local function fmtAny(v)
local t = formatUnitValue(v)
local t = formatUnitValue(v)
return t and mw.text.nowiki(t) or nil
return t and tostring(t) or (v ~= nil and tostring(v) or nil)
end
 
local series = {}
 
-- Preferred: expanded series (wikiprep)
if type(per) == "table" and #per > 0 then
for lv = 1, maxLevel do
local raw = per[lv] or per[#per]
local one = fmtAny(raw)
if one == nil or isZeroish(raw) or isZeroish(one) then
one = "—"
end
series[lv] = one
end
return series
end
 
-- Empty per-list -> base only
if type(per) == "table" and #per == 0 then
local one = fmtAny(base)
if one == nil or isZeroish(base) or isZeroish(one) then
one = "—"
end
for lv = 1, maxLevel do
series[lv] = one
end
return series
end
 
-- Scalar per -> compute base + per*level (fallback)
local baseN = toNum(base) or 0
local perN  = toNum(per)
 
if perN ~= nil then
for lv = 1, maxLevel do
local total = baseN + (perN * lv)
local v = unit and { Value = total, Unit = unit } or total
local one = fmtAny(v)
if one == nil or total == 0 or isZeroish(one) then
one = "—"
end
series[lv] = one
end
return series
end
 
-- Base-only scalar
local raw = (base ~= nil) and base or per
local one = fmtAny(raw)
if one == nil then
return nil
end
if isZeroish(raw) or isZeroish(one) then
one = "—"
end
for lv = 1, maxLevel do
series[lv] = one
end
return series
end
 
local function displayFromSeries(series, level)
if type(series) ~= "table" or #series == 0 then
return nil
end
 
local any = false
for _, v in ipairs(series) do
if v ~= "—" then
any = true
break
end
end
if not any then
return nil
end
 
if isFlatList(series) then
return mw.text.nowiki(series[1])
end
return dynSpan(series, level)
end
 
local function formatAreaSize(area)
if type(area) ~= "table" then
return nil
end
end


local function renderAreaSize(area)
local raw = area["Area Size"]
if type(area) ~= "table" then
if raw == nil then
return nil
return nil
end
 
local name, num
 
if type(raw) == "table" then
name = raw.Name or raw.ID or raw.Value
num  = raw.Value
-- if name accidentally came from Value, prefer Name/ID if present
if raw.Name or raw.ID then
name = raw.Name or raw.ID
end
end
local size = area["Area Size"]
elseif type(raw) == "string" then
if size == nil then
name = raw
elseif type(raw) == "number" then
num = raw
end
 
-- fallback keys that sometimes exist depending on dict/wikiprep
if num == nil then
num = toNum(area["Area Value"]) or toNum(area["Area Size Value"]) or toNum(area["Area Number"]) or toNum(area["Area Radius"])
end
 
if name ~= nil then
local s = mw.text.trim(tostring(name))
if s == "" or isNoneLike(s) then
return nil
return nil
end
end
if type(size) == "table" then
-- if already contains "(...)" keep it
size = size.Name or size.ID or size.Value or size[1] or size
if mw.ustring.find(s, "%(") then
return mw.text.nowiki(s)
end
end
local s = tostring(size or "")
if num ~= nil and num ~= 0 then
s = mw.text.trim(s)
return mw.text.nowiki(string.format("%s (%s)", s, fmtNum(num)))
if s == "" then
return nil
end
end
-- Expecting "Medium (4)"-style strings from wikiprep/dictionary formatting.
return mw.text.nowiki(s)
return mw.text.nowiki(s)
end
end


local function renderCost()
if num ~= nil and num ~= 0 then
local mp = valuePairDynamicValueOnly(rc["Mana Cost"], maxLevel, level)
return mw.text.nowiki(string.format("(%s)", fmtNum(num)))
local hp = valuePairDynamicValueOnly(rc["Health Cost"], maxLevel, level)
end
 
return nil
end
 
local function skillHasAnyDamage(rec, maxLevel)
-- “Has damage/source” for our UI decisions:
-- - New Source with any non-zero at any level
-- - OR legacy damage lists present
if type(rec.Source) == "table" then
local s = seriesFromValuePair(rec.Source, maxLevel)
if s then
for _, v in ipairs(s) do
if v ~= "—" then return true end
end
end
end
 
if type(rec.Damage) == "table" then
local dmg = rec.Damage
for _, key in ipairs({ "Main Damage", "Flat Damage", "Reflect Damage" }) do
local lst = dmg[key]
if type(lst) == "table" and #lst > 0 then
return true
end
end
end
 
return false
end
 
local function buildModuleQuickStats(rec, level, maxLevel, promo)
local mech = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
local rc   = (type(mech["Resource Cost"]) == "table") and mech["Resource Cost"] or {}
 
local function dash() return "—" end
 
-- Range (0 => —)
local rangeVal = nil
if mech.Range ~= nil and not isNoneLike(mech.Range) then
local n = toNum(mech.Range)
if n ~= nil then
if n ~= 0 then
rangeVal = mw.text.nowiki(formatUnitValue(mech.Range) or tostring(mech.Range))
end
else
-- non-numeric range (rare) – show unless “none”
local t = mw.text.trim(tostring(mech.Range))
if t ~= "" and not isNoneLike(t) then
rangeVal = mw.text.nowiki(t)
end
end
end
 
-- Area: Size (Number), none/missing => —
local areaVal = formatAreaSize(mech.Area)


if mp then mp = mp .. " MP" end
-- Timings: 0 => —
if hp then hp = hp .. " HP" end
local castSeries = seriesFromValuePair(bt["Cast Time"], maxLevel)
local cdSeries  = seriesFromValuePair(bt["Cooldown"],  maxLevel)
local durSeries  = seriesFromValuePair(bt["Duration"],  maxLevel)


if mp and hp then
-- Promote status duration if needed
return mp .. " + " .. hp
if (durSeries == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then
durSeries = seriesFromValuePair(promo.durationBlock, maxLevel)
end
 
local castVal = displayFromSeries(castSeries, level)
local cdVal  = displayFromSeries(cdSeries, level)
local durVal  = displayFromSeries(durSeries, level)
 
-- Cost: build combined series, and never show "0 HP"
local function labeledSeries(block, label)
local s = seriesFromValuePair(block, maxLevel)
if not s then return nil end
local any = false
for i, v in ipairs(s) do
if v ~= "—" then
s[i] = tostring(v) .. " " .. label
any = true
else
s[i] = "—"
end
end
end
return mp or hp
return any and s or nil
end
end


local rangeVal = renderAny(mech.Range)
local mpS = labeledSeries(rc["Mana Cost"], "MP")
local areaVal  = renderAreaSize(mech.Area)
local hpS = labeledSeries(rc["Health Cost"], "HP")
local costVal  = renderCost()
 
local costSeries = {}
for lv = 1, maxLevel do
local mp = mpS and mpS[lv] or "—"
local hp = hpS and hpS[lv] or "—"


local castVal = valuePairDynamicValueOnly(bt["Cast Time"], maxLevel, level)
if mp ~= "—" and hp ~= "—" then
local cdVal  = valuePairDynamicValueOnly(bt["Cooldown"],  maxLevel, level)
costSeries[lv] = mp .. " + " .. hp
local durVal  = valuePairDynamicValueOnly(bt["Duration"],  maxLevel, level)
elseif mp ~= "" then
costSeries[lv] = mp
elseif hp ~= "—" then
costSeries[lv] = hp
else
costSeries[lv] = ""
end
end
 
local costVal = displayFromSeries(costSeries, level)


local grid = mw.html.create("div")
local grid = mw.html.create("div")
Line 1,282: Line 1,347:


return moduleBox(4, "module-quick-stats", tostring(grid), false)
return moduleBox(4, "module-quick-stats", tostring(grid), false)
end
local function computeDurationPromotion(rec, maxLevel)
-- Only:
-- - non-damaging skills
-- - AND missing/blank Duration in Basic Timings
-- - AND a Status Application has a Duration block
-- Then: promote that status duration into Module 4 and suppress it in Applies row.
if type(rec) ~= "table" then return nil end
if skillHasAnyDamage(rec, maxLevel) then return nil end
local mech = (type(rec.Mechanics) == "table") and rec.Mechanics or {}
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
local durS = seriesFromValuePair(bt["Duration"], maxLevel)
if durS ~= nil then
-- Duration exists (even if some entries are —), so we don’t promote.
return nil
end
local apps = rec["Status Applications"]
if type(apps) ~= "table" then return nil end
for idx, app in ipairs(apps) do
if type(app) == "table" and type(app.Duration) == "table" then
local s = seriesFromValuePair(app.Duration, maxLevel)
if s then
for _, v in ipairs(s) do
if v ~= "—" then
return {
durationBlock = app.Duration,
suppressDurationIndex = idx,
}
end
end
end
end
end
return nil
end
end


Line 1,287: Line 1,392:
local grid = mw.html.create("div")
local grid = mw.html.create("div")
grid:addClass("hero-modules-grid")
grid:addClass("hero-modules-grid")
local nonDamaging = false
do
local dmgVal = nil
if type(rec.Type) == "table" then
dmgVal = rec.Type.Damage or rec.Type["Damage Type"]
if type(dmgVal) == "table" then
dmgVal = dmgVal.Name or dmgVal.ID or dmgVal.Value
end
end
nonDamaging = isNoneLike(dmgVal) or (not skillHasAnyDamage(rec, maxLevel))
end
local promo = computeDurationPromotion(rec, maxLevel)


grid:wikitext(buildModuleLevelSelector(level, maxLevel))
grid:wikitext(buildModuleLevelSelector(level, maxLevel))
grid:wikitext(buildModuleSkillType(rec.Type or {}))
grid:wikitext(buildModuleSkillType(rec.Type or {}, nonDamaging))


local m3 = buildModuleSkillSource(rec, level, maxLevel)
local m3 = buildModuleSkillSource(rec, level, maxLevel)
grid:wikitext(m3 or buildEmptyModule(3))
grid:wikitext(m3 or buildEmptyModule(3))


-- Slot 4: new Quick Stats module
grid:wikitext(buildModuleQuickStats(rec, level, maxLevel, promo))
grid:wikitext(buildModuleQuickStats(rec, level, maxLevel))


return tostring(grid)
return tostring(grid), promo
end
end


Line 1,383: Line 1,501:
end
end


addHeroModulesRow(root, buildHeroModulesUI(rec, level, maxLevel))
local modulesUI, promo = buildHeroModulesUI(rec, level, maxLevel)
addHeroModulesRow(root, modulesUI)


if showUsers then
if showUsers then
Line 1,420: Line 1,539:
local mech = rec.Mechanics or {}
local mech = rec.Mechanics or {}
if next(mech) ~= nil then
if next(mech) ~= nil then
-- NOTE: Range / Area / Timing / Resource Cost rows have been removed
-- Range / Area / Timing / Resource Cost rows are removed:
-- because they are now displayed in Module 4 (Quick Stats).
-- They’re now covered by Module 4.
 
if mech["Autocast Multiplier"] ~= nil then
if mech["Autocast Multiplier"] ~= nil then
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
Line 1,464: Line 1,582:
end
end


local statusApps = formatStatusApplications(rec["Status Applications"], maxLevel, level)
local suppressIdx = (type(promo) == "table") and promo.suppressDurationIndex or nil
local statusApps = formatStatusApplications(rec["Status Applications"], maxLevel, level, suppressIdx)
local statusRem  = formatStatusRemoval(rec["Status Removal"], maxLevel, level)
local statusRem  = formatStatusRemoval(rec["Status Removal"], maxLevel, level)
if statusApps or statusRem then
if statusApps or statusRem then