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
Tag: Reverted
No edit summary
 
(17 intermediate revisions by the same user not shown)
Line 3: Line 3:
-- Phase 6.5+ (Plug-in Slot Architecture)
-- Phase 6.5+ (Plug-in Slot Architecture)
--
--
-- Standard Hero Layout (reused across the wiki):
-- Layout:
--  1) hero-title-bar        (TOP BAR, 2 slots: herobar 1..2)
--  1) hero-title-bar        (TOP BAR, 2 slots: herobar 1..2)
--        - Herobar 1: Icon + Skill Name (left)
--  2) hero-description-bar  (description strip)
--        - Herobar 2: Reserved for compact info (right)
--  2) hero-description-bar  (description strip) [special-cased for now]
--  3) hero-modules row      (4 slots: hero-module-1..4)
--  3) hero-modules row      (4 slots: hero-module-1..4)
--        - Slot 1: Level Selector
--        - Slot 2: Skill Type
--        - Slot 3: SourceType (Modifier + Source + Scaling) OR blank
--        - Slot 4: Quick Stats (Range/Area/Cost/Cast/Cooldown/Duration)
--
--
-- Requires Common.js logic that updates:
-- Requires Common.js:
--  - .sv-dyn spans via data-series
--  - updates .sv-dyn spans via data-series
--  - .sv-level-num + data-level on .sv-skill-card
--  - updates .sv-level-num + data-level on .sv-skill-card
--  - binds to input.sv-level-range inside each card
--  - binds to input.sv-level-range inside each card


Line 29: Line 23:
local skillsCache
local skillsCache


-- getSkills: lazy-load + cache skill dataset from GameData.
local function getSkills()
local function getSkills()
if not skillsCache then
if not skillsCache then
Line 40: Line 35:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- getArgs: read args from parent frame when invoked from a template.
local function getArgs(frame)
local function getArgs(frame)
local parent = frame:getParent()
local parent = frame:getParent()
Line 45: Line 41:
end
end


-- trim: normalize strings (nil if empty).
local function trim(s)
local function trim(s)
if type(s) ~= "string" then
if type(s) ~= "string" then
Line 53: Line 50:
end
end


-- toNum: convert common scalar/table forms to a Lua number.
local function toNum(v)
local function toNum(v)
if type(v) == "number" then
if type(v) == "number" then
Line 66: Line 64:
end
end


-- clamp: clamp a number into [lo, hi].
local function clamp(n, lo, hi)
local function clamp(n, lo, hi)
if type(n) ~= "number" then
if type(n) ~= "number" then
Line 75: Line 74:
end
end


-- fmtNum: consistent number formatting (trim trailing zeros).
local function fmtNum(n)
local function fmtNum(n)
if type(n) ~= "number" then
if type(n) ~= "number" then
Line 90: Line 90:
end
end


-- listToText: join an array into a readable string.
local function listToText(list, sep)
local function listToText(list, sep)
if type(list) ~= "table" or #list == 0 then
if type(list) ~= "table" or #list == 0 then
Line 97: Line 98:
end
end


-- isNoneLike: treat common "none" spellings as empty.
local function isNoneLike(v)
local function isNoneLike(v)
if v == nil then return true end
if v == nil then return true end
Line 105: Line 107:
end
end


-- Add a labeled infobox row (with optional hooks for future)
-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row.
local function addRow(tbl, label, value, rowClass, dataKey)
local function addRow(tbl, label, value, rowClass, dataKey)
if value == nil or value == "" then
if value == nil or value == "" then
Line 113: Line 115:
local row = tbl:tag("tr")
local row = tbl:tag("tr")
row:addClass("sv-row")
row:addClass("sv-row")
if rowClass then
if rowClass then row:addClass(rowClass) end
row:addClass(rowClass)
if dataKey then row:attr("data-field", dataKey) end
end
if dataKey then
row:attr("data-field", dataKey)
end


row:tag("th"):wikitext(label):done()
row:tag("th"):wikitext(label):done()
Line 124: Line 122:
end
end


-- Handles either a scalar OR { Value = ..., Unit = ... }
-- formatUnitValue: format {Value, Unit} blocks (or scalar) for display.
-- IMPORTANT: wikiprep often converts unit-wrapped pairs into strings already.
local function formatUnitValue(v)
local function formatUnitValue(v)
if type(v) == "table" and v.Value ~= nil then
if type(v) == "table" and v.Value ~= nil then
Line 153: Line 150:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- dynSpan: render a JS-updated span for a level series.
local function dynSpan(series, level)
local function dynSpan(series, level)
if type(series) ~= "table" or #series == 0 then
if type(series) ~= "table" or #series == 0 then
Line 168: Line 166:
end
end


-- isFlatList: true if all values in list are identical.
local function isFlatList(list)
local function isFlatList(list)
if type(list) ~= "table" or #list == 0 then
if type(list) ~= "table" or #list == 0 then
Line 181: Line 180:
end
end


-- isNonZeroScalar: detect if a value is present and not effectively zero.
local function isNonZeroScalar(v)
local function isNonZeroScalar(v)
if v == nil then
if v == nil then return false end
return false
if type(v) == "number" then return v ~= 0 end
end
if type(v) == "number" then
return v ~= 0
end
if type(v) == "string" then
if type(v) == "string" then
local n = tonumber(v)
local n = tonumber(v)
if n == nil then
if n == nil then return v ~= "" end
return v ~= ""
end
return n ~= 0
return n ~= 0
end
end
Line 201: Line 195:
end
end


-- isZeroish: aggressively treat common “zero” text forms as zero.
local function isZeroish(v)
local function isZeroish(v)
if v == nil then return true end
if v == nil then return true end
Line 207: Line 202:
return isZeroish(v.Value)
return isZeroish(v.Value)
end
end
local s = mw.text.trim(tostring(v))
local s = mw.text.trim(tostring(v))
if s == "" then return true end
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 == "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 == "0s" or s == "0 s" then return true end
if s == "0m" or s == "0 m" 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
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%.%-]", "")))
local n = tonumber((mw.ustring.gsub(s, "[^0-9%.%-]", "")))
return (n ~= nil and n == 0)
return (n ~= nil and n == 0)
end
end


-- Base/Per Level renderer:
-- valuePairRawText: render Base/Per Level blocks into readable text (fallback).
-- - Per Level list -> dynamic span (or single value if flat)
local function valuePairRawText(block)
-- - Per Level scalar -> "Base" + "Per Level" lines
local function valuePairDynamicLines(name, block, maxLevel, level)
if type(block) ~= "table" then
if type(block) ~= "table" then
return {}
return nil
end
end


Line 230: Line 223:
local per  = block["Per Level"]
local per  = block["Per Level"]


-- Per Level list (expanded by wikiprep)
if type(per) == "table" then
if type(per) == "table" then
if #per == 0 then
if #per == 0 then
local baseText = formatUnitValue(base)
return formatUnitValue(base)
return baseText and { string.format("%s: %s", name, mw.text.nowiki(baseText)) } or {}
end
end
if isFlatList(per) then
if isFlatList(per) then
local baseText = formatUnitValue(base)
return formatUnitValue(base) or tostring(per[1])
local one = formatUnitValue(per[1]) or tostring(per[1])
local show = baseText or one
return show and { string.format("%s: %s", name, mw.text.nowiki(show)) } or {}
end
end


local series = {}
local vals = {}
for _, v in ipairs(per) do
for _, v in ipairs(per) do
table.insert(series, formatUnitValue(v) or tostring(v))
table.insert(vals, formatUnitValue(v) or tostring(v))
end
end
 
return (#vals > 0) and table.concat(vals, " / ") or nil
local dyn = dynSpan(series, level)
return dyn and { string.format("%s: %s", name, dyn) } or {}
end
end


-- scalar Per Level
local lines = {}
local baseText = formatUnitValue(base)
local baseText = formatUnitValue(base)
local perText  = formatUnitValue(per)
local perText  = formatUnitValue(per)


if baseText then
if baseText and perText and isNonZeroScalar(per) then
table.insert(lines, string.format("%s: %s", name, mw.text.nowiki(baseText)))
return string.format("%s (Per Level: %s)", baseText, perText)
end
if perText and isNonZeroScalar(per) then
table.insert(lines, string.format("%s Per Level: %s", name, mw.text.nowiki(perText)))
end
end


return lines
return baseText or perText
end
end


local function valuePairDynamicText(name, block, maxLevel, level, sep)
-- valuePairDynamicValueOnly: render Base/Per Level blocks using dyn spans where possible.
local lines = valuePairDynamicLines(name, block, maxLevel, level)
local function valuePairDynamicValueOnly(block, maxLevel, level)
return (#lines > 0) and table.concat(lines, sep or "<br />") or nil
end
 
local function valuePairRawText(block)
if type(block) ~= "table" then
if type(block) ~= "table" then
return nil
return nil
Line 283: Line 259:
if type(per) == "table" then
if type(per) == "table" then
if #per == 0 then
if #per == 0 then
return formatUnitValue(base)
local baseText = formatUnitValue(base)
return baseText and mw.text.nowiki(baseText) or nil
end
end
if isFlatList(per) then
if isFlatList(per) then
return formatUnitValue(base) or tostring(per[1])
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
end


local vals = {}
local series = {}
for _, v in ipairs(per) do
for _, v in ipairs(per) do
table.insert(vals, formatUnitValue(v) or tostring(v))
table.insert(series, formatUnitValue(v) or tostring(v))
end
end
return (#vals > 0) and table.concat(vals, " / ") or nil
return dynSpan(series, level)
end
end


local baseText = formatUnitValue(base)
local txt = valuePairRawText(block)
local perText  = formatUnitValue(per)
return txt and mw.text.nowiki(txt) or nil
end


if baseText and perText and isNonZeroScalar(per) then
----------------------------------------------------------------------
return string.format("%s (Per Level: %s)", baseText, perText)
-- Lookups
end
----------------------------------------------------------------------


return baseText or perText
-- getSkillById: locate a skill by internal ID.
local function getSkillById(id)
id = trim(id)
if not id then return nil end
local dataset = getSkills()
return (dataset.byId or {})[id]
end
end


-- Prefer “value only” (dynSpan when series list exists)
-- findSkillByName: locate a skill by external/display name.
local function valuePairDynamicValueOnly(block, maxLevel, level)
local function findSkillByName(name)
if type(block) ~= "table" then
name = trim(name)
return nil
if not name then return nil end
 
local dataset = getSkills()
local byName = dataset.byName or {}
 
if byName[name] then
return byName[name]
end
end


local base = block.Base
for _, rec in ipairs(dataset.records or {}) do
local per  = block["Per Level"]
if type(rec) == "table" then
 
if rec["External Name"] == name or rec.Name == name or rec["Display Name"] == name then
if type(per) == "table" then
return rec
if #per == 0 then
end
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
end
return dynSpan(series, level)
end
end


local txt = valuePairRawText(block)
return nil
return txt and mw.text.nowiki(txt) or nil
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Lookups
-- Legacy damage helpers
----------------------------------------------------------------------
----------------------------------------------------------------------


local function getSkillById(id)
-- basisLabel: label ATK/MATK basis in legacy damage blocks.
id = trim(id)
local function basisLabel(entry, isHealing)
if not id then
if isHealing then
return nil
return "Healing"
end
 
local atk  = entry and entry["ATK-Based"]
local matk = entry and entry["MATK-Based"]
 
if atk and matk then
return "Attack/Magic Attack"
elseif atk then
return "Attack"
elseif matk then
return "Magic Attack"
end
end
local dataset = getSkills()
 
return (dataset.byId or {})[id]
return "Damage"
end
end


local function findSkillByName(name)
-- formatDamageEntry: legacy percent damage formatting (dynamic by level).
name = trim(name)
local function formatDamageEntry(entry, maxLevel, level)
if not name then
if type(entry) ~= "table" then
return nil
return nil
end
end


local dataset = getSkills()
local isHealing = (entry.Type == "Healing")
local byName = dataset.byName or {}
local basis = isHealing and "Healing" or basisLabel(entry, false)


if byName[name] then
local baseRaw = entry["Base %"]
return byName[name]
local perRaw  = entry["Per Level %"]
end
 
local baseN = toNum(baseRaw)
local perN  = toNum(perRaw)


for _, rec in ipairs(dataset.records or {}) do
local function baseIsPresent()
if type(rec) == "table" then
if baseN ~= nil then
if rec["External Name"] == name or rec.Name == name or rec["Display Name"] == name then
return baseN ~= 0
return rec
end
end
if baseRaw ~= nil then
local s = tostring(baseRaw)
return (s ~= "" and s ~= "0" and s ~= "0.0" and s ~= "0.00")
end
end
return false
end
end


return nil
local baseText
end
if baseIsPresent() then
baseText = (baseN ~= nil) and (fmtNum(baseN) .. "%") or (tostring(baseRaw) .. "%")
end


----------------------------------------------------------------------
if perN == nil or perN == 0 or not maxLevel or maxLevel <= 0 then
-- Formatting helpers (legacy + mechanics/status/etc.)
return baseText and mw.text.nowiki(baseText .. " " .. basis) or nil
----------------------------------------------------------------------
end
 
local series = {}
for lv = 1, maxLevel do
local perPart = perN * lv


local function basisLabel(entry, isHealing)
if baseText and baseN ~= nil then
if isHealing then
local total = baseN + perPart
return "Healing"
table.insert(series, string.format("%s%% %s", fmtNum(total), basis))
elseif baseText then
table.insert(series, string.format("%s + %s%% %s", baseText, fmtNum(perPart), basis))
else
table.insert(series, string.format("%s%% %s", fmtNum(perPart), basis))
end
end
end


local atk  = entry and entry["ATK-Based"]
return dynSpan(series, level)
local matk = entry and entry["MATK-Based"]
end


if atk and matk then
-- formatDamageList: render a list of legacy damage entries into <br/> blocks.
return "Attack/Magic Attack"
local function formatDamageList(list, maxLevel, level, includeTypePrefix)
elseif atk then
if type(list) ~= "table" or #list == 0 then
return "Attack"
return nil
elseif matk then
end
return "Magic Attack"
 
local parts = {}
for _, d in ipairs(list) do
if type(d) == "table" then
local txt = formatDamageEntry(d, maxLevel, level)
if txt then
if includeTypePrefix and d.Type and d.Type ~= "" then
table.insert(parts, mw.text.nowiki(tostring(d.Type) .. ": ") .. txt)
else
table.insert(parts, txt)
end
end
end
end
end


return "Damage"
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
end


local function formatDamageEntry(entry, maxLevel, level)
----------------------------------------------------------------------
if type(entry) ~= "table" then
-- Users matching
return nil
----------------------------------------------------------------------
 
-- skillMatchesUser: check if a skill is used by a specific class/monster/summon/event.
local function skillMatchesUser(rec, userName)
if type(rec) ~= "table" or not userName or userName == "" then
return false
end
end


local isHealing = (entry.Type == "Healing")
local users = rec.Users
local basis = isHealing and "Healing" or basisLabel(entry, false)
if type(users) ~= "table" then
return false
end


local baseRaw = entry["Base %"]
local userLower = mw.ustring.lower(userName)
local perRaw  = entry["Per Level %"]


local baseN = toNum(baseRaw)
local function listHas(list)
local perN  = toNum(perRaw)
if type(list) ~= "table" then
 
return false
local function baseIsPresent()
if baseN ~= nil then
return baseN ~= 0
end
end
if baseRaw ~= nil then
for _, v in ipairs(list) do
local s = tostring(baseRaw)
if type(v) == "string" and mw.ustring.lower(v) == userLower then
return (s ~= "" and s ~= "0" and s ~= "0.0" and s ~= "0.00")
return true
end
end
end
return false
return false
end
end


local baseText
return listHas(users.Classes) or listHas(users.Summons) or listHas(users.Monsters) or listHas(users.Events)
if baseIsPresent() then
end
baseText = (baseN ~= nil) and (fmtNum(baseN) .. "%") or (tostring(baseRaw) .. "%")
 
----------------------------------------------------------------------
-- Hide Users on direct skill pages
----------------------------------------------------------------------
 
-- isDirectSkillPage: hide Users rows when viewing the skill page itself.
local function isDirectSkillPage(rec)
if type(rec) ~= "table" then
return false
end
end


if perN == nil or perN == 0 or not maxLevel or maxLevel <= 0 then
local pageTitle = mw.title.getCurrentTitle()
return baseText and mw.text.nowiki(baseText .. " " .. basis) or nil
local pageName  = pageTitle and pageTitle.text or ""
pageName = trim(pageName)
if not pageName then
return false
end
end


local series = {}
pageName = mw.ustring.lower(pageName)
for lv = 1, maxLevel do
local perPart = perN * lv


if baseText and baseN ~= nil then
local ext = trim(rec["External Name"] or rec.Name or rec["Display Name"])
local total = baseN + perPart
local internal = trim(rec["Internal Name"] or rec.InternalName or rec.InternalID)
table.insert(series, string.format("%s%% %s", fmtNum(total), basis))
elseif baseText then
table.insert(series, string.format("%s + %s%% %s", baseText, fmtNum(perPart), basis))
else
table.insert(series, string.format("%s%% %s", fmtNum(perPart), basis))
end
end


return dynSpan(series, level)
return (ext and mw.ustring.lower(ext) == pageName) or (internal and mw.ustring.lower(internal) == pageName) or false
end
end


local function formatDamageList(list, maxLevel, level, includeTypePrefix)
----------------------------------------------------------------------
if type(list) ~= "table" or #list == 0 then
-- Slot config (edit these tables only to rearrange layout)
return nil
----------------------------------------------------------------------
end
 
local HERO_BAR_SLOT_ASSIGNMENT = {
[1] = "IconName",
[2] = "SkillType", -- Damage/Element/Hits/Target/Cast/Combo strip
}


local parts = {}
local HERO_MODULE_SLOT_ASSIGNMENT = {
for _, d in ipairs(list) do
[1] = "SourceType",
if type(d) == "table" then
[2] = "QuickStats",
local txt = formatDamageEntry(d, maxLevel, level)
[3] = "SpecialMechanics",
if txt then
[4] = "LevelSelector",
if includeTypePrefix and d.Type and d.Type ~= "" then
}
table.insert(parts, mw.text.nowiki(tostring(d.Type) .. ": ") .. txt)
 
else
----------------------------------------------------------------------
table.insert(parts, txt)
-- Slot scaffolds
end
----------------------------------------------------------------------
end
end
end


return (#parts > 0) and table.concat(parts, "<br />") or nil
-- heroBarBox: wrapper for hero-bar slot modules.
end
local function heroBarBox(slot, extraClasses, innerHtml, isEmpty)
local box = mw.html.create("div")
box:addClass("hero-bar-module")
box:addClass("hero-bar-module-" .. tostring(slot))
box:attr("data-hero-bar-module", tostring(slot))


local function formatCombo(combo)
if slot == 2 then
if type(combo) ~= "table" then
box:addClass("sv-herobar-compact")
return nil
end
end


local parts = {}
if extraClasses then
 
if type(extraClasses) == "string" then
if combo.Type then
box:addClass(extraClasses)
table.insert(parts, "Type: " .. mw.text.nowiki(tostring(combo.Type)))
elseif type(extraClasses) == "table" then
for _, c in ipairs(extraClasses) do box:addClass(c) end
end
end
end


local durText = formatUnitValue(combo.Duration)
if isEmpty then
if durText then
box:addClass("hero-bar-module-empty")
table.insert(parts, "Duration: " .. mw.text.nowiki(durText))
end
end


if combo.Percent ~= nil then
local body = box:tag("div"):addClass("hero-bar-module-body")
local pctText = formatUnitValue(combo.Percent)
if innerHtml and innerHtml ~= "" then
if pctText then
body:wikitext(innerHtml)
table.insert(parts, "Bonus: " .. mw.text.nowiki(pctText))
end
end
end


return (#parts > 0) and table.concat(parts, ", ") or nil
return tostring(box)
end
end


local function formatMechanicEffects(effects, maxLevel, level)
-- moduleBox: wrapper for hero-module (2x2 grid) slot modules.
if type(effects) ~= "table" then
local function moduleBox(slot, extraClasses, innerHtml, isEmpty)
return nil
local box = mw.html.create("div")
end
box:addClass("hero-module")
box:addClass("hero-module-" .. tostring(slot))
box:attr("data-hero-module", tostring(slot))


local keys = {}
if extraClasses then
for k, _ in pairs(effects) do
if type(extraClasses) == "string" then
table.insert(keys, k)
box:addClass(extraClasses)
elseif type(extraClasses) == "table" then
for _, c in ipairs(extraClasses) do box:addClass(c) end
end
end
end
table.sort(keys)


local parts = {}
if isEmpty then
box:addClass("hero-module-empty")
end
 
local body = box:tag("div"):addClass("hero-module-body")
if innerHtml and innerHtml ~= "" then
body:wikitext(innerHtml)
end


local function effectAmount(block)
return tostring(box)
if type(block) ~= "table" then
end
return nil
end


local per = block["Per Level"]
----------------------------------------------------------------------
if type(per) == "table" and #per > 0 then
-- Shared helpers (Source + QuickStats + SpecialMechanics)
if isFlatList(per) then
----------------------------------------------------------------------
return mw.text.nowiki(formatUnitValue(per[1]) or tostring(per[1]))
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)
-- formatScalingCompactLines: build compact “Scaling” lines for SourceType.
return txt and mw.text.nowiki(txt) or nil
local function formatScalingCompactLines(scaling)
if type(scaling) ~= "table" then
return {}
end
end


for _, name in ipairs(keys) do
local list = scaling
local block = effects[name]
if #list == 0 then
if type(block) == "table" then
if scaling.Percent ~= nil or scaling["Scaling ID"] or scaling["Scaling Name"] then
local t = block.Type
list = { scaling }
else
return {}
end
end
 
local out = {}
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)


if t ~= nil and tostring(t) ~= "" then
if pctN ~= nil and pctN ~= 0 then
local amt = effectAmount(block)
table.insert(out, string.format("%s%% %s", fmtNum(pctN), stat))
local seg = mw.text.nowiki(tostring(t) .. " - " .. tostring(name))
elseif pct ~= nil and tostring(pct) ~= "" and tostring(pct) ~= "0" then
if amt then
table.insert(out, string.format("%s%% %s", tostring(pct), stat))
seg = seg .. " + " .. amt
end
table.insert(parts, seg)
else
local txt = valuePairDynamicText(name, block, maxLevel, level, ", ")
if txt then
table.insert(parts, txt)
end
end
end
end
end
end
end


return (#parts > 0) and table.concat(parts, "<br />") or nil
return out
end
 
-- basisWordFromFlags: compute “Attack/Magic/Hybrid” from ATK/MATK booleans.
local function basisWordFromFlags(atkFlag, matkFlag)
if atkFlag and matkFlag then
return "Hybrid"
elseif atkFlag then
return "Attack"
elseif matkFlag then
return "Magic Attack"
end
return nil
end
end


local function formatModifiers(mods)
-- legacyPercentAtLevel: compute “Base% + PerLevel%*level” for legacy entries.
if type(mods) ~= "table" then
local function legacyPercentAtLevel(entry, level)
if type(entry) ~= "table" then
return nil
return nil
end
end


local parts = {}
local baseRaw = entry["Base %"]
local perRaw  = entry["Per Level %"]
local baseN  = toNum(baseRaw)
local perN    = toNum(perRaw)


local function collect(label, sub)
if perN ~= nil and perN ~= 0 then
if type(sub) ~= "table" then
local total = (baseN or 0) + (perN * level)
return
return fmtNum(total) .. "%"
end
end


local flags = {}
if baseN ~= nil then
for k, v in pairs(sub) do
return fmtNum(baseN) .. "%"
if v then
end
table.insert(flags, k)
if baseRaw ~= nil and tostring(baseRaw) ~= "" then
end
return tostring(baseRaw) .. "%"
end
table.sort(flags)
 
if #flags > 0 then
table.insert(parts, string.format("%s: %s", label, table.concat(flags, ", ")))
end
end
end


collect("Movement", mods["Movement Modifiers"])
return nil
collect("Combat",  mods["Combat Modifiers"])
collect("Special",  mods["Special Modifiers"])
 
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
end


local function formatStatusApplications(list, maxLevel, level, suppressDurationIndex)
-- seriesFromValuePair: normalize Base/Per Level blocks into a level-indexed series.
if type(list) ~= "table" or #list == 0 then
local function seriesFromValuePair(block, maxLevel)
if type(block) ~= "table" then
return nil
return nil
end
end


local parts = {}
local base = block.Base
for idx, s in ipairs(list) do
local per = block["Per Level"]
if type(s) == "table" then
local typ = s.Type or s.Scope or "Target"
local name = s["Status External Name"] or s["Status Internal Name"] or "Unknown status"


local seg = tostring(typ) .. " " .. tostring(name)
local function pickUnit(v)
local detail = {}
if type(v) == "table" and v.Unit and v.Unit ~= "" then
return v.Unit
end
return nil
end
local unit = pickUnit(base) or pickUnit(per)


-- Duration (optionally suppressed if QuickStats promoted it)
local function fmtAny(v)
if idx ~= suppressDurationIndex and type(s.Duration) == "table" then
local t = formatUnitValue(v)
local t = valuePairDynamicText("Duration", s.Duration, maxLevel, level, "; ")
return t and tostring(t) or (v ~= nil and tostring(v) or nil)
if t then table.insert(detail, t) end
end
end


if type(s.Chance) == "table" then
local series = {}
local t = valuePairDynamicText("Chance", s.Chance, maxLevel, level, "; ")
if t then table.insert(detail, t) end
end


if #detail > 0 then
-- Expanded per-level series (wikiprep)
seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
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
end
series[lv] = one
end
return series
end


table.insert(parts, seg)
-- 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
end
return series
end
end


return (#parts > 0) and table.concat(parts, "<br />") or nil
-- Scalar per -> compute base + per*level (fallback)
end
local baseN = toNum(base) or 0
local perN  = toNum(per)


local function formatStatusRemoval(list, maxLevel, level)
if perN ~= nil then
if type(list) ~= "table" or #list == 0 then
for lv = 1, maxLevel do
return nil
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
end


local parts = {}
-- Base-only scalar
for _, r in ipairs(list) do
local raw = (base ~= nil) and base or per
if type(r) == "table" then
local one = fmtAny(raw)
local names = r["Status External Name"]
if one == nil then
local label
return nil
 
end
if type(names) == "table" then
if isZeroish(raw) or isZeroish(one) then
label = table.concat(names, ", ")
one = ""
elseif type(names) == "string" then
end
label = names
for lv = 1, maxLevel do
else
series[lv] = one
label = "Status"
end
 
local amt
if type(r["Per Level"]) == "table" and #r["Per Level"] > 0 and not isFlatList(r["Per Level"]) then
local series = {}
for _, v in ipairs(r["Per Level"]) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
amt = dynSpan(series, level)
else
amt = valuePairRawText(r)
amt = amt and mw.text.nowiki(amt) or nil
end
 
local seg = mw.text.nowiki(label)
if amt then
seg = seg .. " – " .. amt
end
table.insert(parts, seg)
end
end
end
 
return series
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
end


local function formatEvents(list)
-- displayFromSeries: render a series as fixed text or dynSpan (nil if all “—”).
if type(list) ~= "table" or #list == 0 then
local function displayFromSeries(series, level)
if type(series) ~= "table" or #series == 0 then
return nil
return nil
end
end


local parts = {}
local any = false
for _, ev in ipairs(list) do
for _, v in ipairs(series) do
if type(ev) == "table" then
if v ~= "" then
local action = ev.Action or "On event"
any = true
local name  = ev["Skill Internal Name"] or ev["Skill External Name"] or "Unknown skill"
break
table.insert(parts, string.format("%s → %s", action, name))
end
end
end
if not any then
return nil
end
end


return (#parts > 0) and table.concat(parts, "<br />") or nil
if isFlatList(series) then
return mw.text.nowiki(series[1])
end
return dynSpan(series, level)
end
end


----------------------------------------------------------------------
-- formatAreaSize: human readable area sizing for QuickStats.
-- User matching (for auto lists on class pages)
-- Shows: "<Area Size> (<number>)" e.g. "Medium (4)"
----------------------------------------------------------------------
local function formatAreaSize(area, maxLevel, level)
 
if type(area) ~= "table" then
local function skillMatchesUser(rec, userName)
return nil
if type(rec) ~= "table" or not userName or userName == "" then
return false
end
end


local users = rec.Users
-- Helper: pull a number from scalar/unit/valuepair-ish things.
if type(users) ~= "table" then
local function extractNumber(v)
return false
if v == nil then return nil end
end


local userLower = mw.ustring.lower(userName)
-- Unit block {Value, Unit}
if type(v) == "table" and v.Value ~= nil then
local n = toNum(v.Value)
return n
end


local function listHas(list)
-- ValuePair {Base, Per Level} -> prefer Base at current level if series exists
if type(list) ~= "table" then
if type(v) == "table" and (v.Base ~= nil or v["Per Level"] ~= nil) then
return false
local s = seriesFromValuePair(v, maxLevel or 1)
if type(s) == "table" and #s > 0 then
local idx = clamp(level or 1, 1, #s)
local txt = s[idx]
if txt and txt ~= "" then
-- try parse numeric from string (e.g. "4 tiles" -> 4)
local num = tonumber((mw.ustring.gsub(tostring(txt), "[^0-9%.%-]", "")))
return num
end
end
return nil
end
end
for _, v in ipairs(list) do
 
if type(v) == "string" and mw.ustring.lower(v) == userLower then
-- Plain scalar
return true
if type(v) == "number" then return v end
end
if type(v) == "string" then
local num = tonumber((mw.ustring.gsub(mw.text.trim(v), "[^0-9%.%-]", "")))
return num
end
end
return false
 
return nil
end
end


return listHas(users.Classes) or listHas(users.Summons) or listHas(users.Monsters) or listHas(users.Events)
-- 1) Read Area Size label/name
end
local rawSize = area["Area Size"]
 
if rawSize == nil then
----------------------------------------------------------------------
return nil
-- Direct page detection (hide Users on the skill's own page)
end
----------------------------------------------------------------------


local function isDirectSkillPage(rec)
local sizeName = nil
if type(rec) ~= "table" then
if type(rawSize) == "table" then
return false
sizeName = rawSize.Name or rawSize.ID or rawSize.Value
elseif type(rawSize) == "string" then
sizeName = rawSize
elseif type(rawSize) == "number" then
-- uncommon; treat as numeric-only label
sizeName = tostring(rawSize)
end
end


local pageTitle = mw.title.getCurrentTitle()
sizeName = sizeName and mw.text.trim(tostring(sizeName)) or nil
local pageName  = pageTitle and pageTitle.text or ""
if not sizeName or sizeName == "" or isNoneLike(sizeName) then
pageName = trim(pageName)
return nil
if not pageName then
return false
end
end


pageName = mw.ustring.lower(pageName)
-- 2) Find the numeric “exact number” to append
-- Prefer the explicit Area Distance block, then fall back to other known numeric keys.
local num = nil


local ext = trim(rec["External Name"] or rec.Name or rec["Display Name"])
local dist = area["Area Distance"]
local internal = trim(rec["Internal Name"] or rec.InternalName or rec.InternalID)
if type(dist) == "table" then
-- Prefer Effective Distance if present and non-zero, else Base
num = extractNumber(dist["Effective Distance"]) or extractNumber(dist.Effective) or extractNumber(dist["Effective"])
if not num or num == 0 then
num = extractNumber(dist.Base) or extractNumber(dist["Base"])
end
end


return (ext and mw.ustring.lower(ext) == pageName) or (internal and mw.ustring.lower(internal) == pageName) or false
if not num or num == 0 then
end
num =
extractNumber(area["Area Value"]) or
extractNumber(area["Area Size Value"]) or
extractNumber(area["Area Number"]) or
extractNumber(area["Area Radius"])
end


----------------------------------------------------------------------
-- 3) Render
-- Plug-in slot config (EDIT THESE TABLES ONLY to rearrange layout)
-- If size already contains parentheses, assume it already includes the numeric.
----------------------------------------------------------------------
if mw.ustring.find(sizeName, "%(") then
return mw.text.nowiki(sizeName)
end


-- Hero bar: 2 slots
if num and num ~= 0 then
local HERO_BAR_SLOT_ASSIGNMENT = {
return mw.text.nowiki(string.format("%s (%s)", sizeName, fmtNum(num)))
[1] = "SourceType",
end
[2] = "ReservedInfo", -- set to nil to blank the slot entirely
}


-- Modules row: 4 slots
return mw.text.nowiki(sizeName)
local HERO_MODULE_SLOT_ASSIGNMENT = {
end
[1] = "LevelSelector",
[2] = "SkillType",
[3] = "IconName",  -- returns nil/"" when not applicable -> becomes blank slot
[4] = "QuickStats",
}


----------------------------------------------------------------------
-- skillHasAnyDamage: determine if a skill has any meaningful damage (for non-damaging rules).
-- Slot scaffolds (stable containers; plug-ins provide inner content)
local function skillHasAnyDamage(rec, maxLevel)
----------------------------------------------------------------------
if type(rec.Source) == "table" then
 
local s = seriesFromValuePair(rec.Source, maxLevel)
local function heroBarBox(slot, extraClasses, innerHtml, isEmpty)
if s then
local box = mw.html.create("div")
for _, v in ipairs(s) do
box:addClass("hero-bar-module")
if v ~= "" then return true end
box:addClass("hero-bar-module-" .. tostring(slot))
end
box:attr("data-hero-bar-module", tostring(slot))
end
end


if extraClasses then
if type(rec.Damage) == "table" then
if type(extraClasses) == "string" then
local dmg = rec.Damage
box:addClass(extraClasses)
for _, key in ipairs({ "Main Damage", "Flat Damage", "Reflect Damage" }) do
elseif type(extraClasses) == "table" then
local lst = dmg[key]
for _, c in ipairs(extraClasses) do
if type(lst) == "table" and #lst > 0 then
box:addClass(c)
return true
end
end
end
end
end
end


if isEmpty then
return false
box:addClass("hero-bar-module-empty")
end
end


local body = box:tag("div"):addClass("hero-bar-module-body")
-- computeDurationPromotion: promote status-duration into QuickStats when a skill is non-damaging.
if innerHtml and innerHtml ~= "" then
local function computeDurationPromotion(rec, maxLevel)
body:wikitext(innerHtml)
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
local any = false
for _, v in ipairs(durS) do
if v ~= "" then any = true break end
end
if any then
return nil
end
end
end


return tostring(box)
local apps = rec["Status Applications"]
end
if type(apps) ~= "table" then return nil end


local function moduleBox(slot, extraClasses, innerHtml, isEmpty)
for idx, app in ipairs(apps) do
local box = mw.html.create("div")
if type(app) == "table" and type(app.Duration) == "table" then
box:addClass("hero-module")
local s = seriesFromValuePair(app.Duration, maxLevel)
box:addClass("hero-module-" .. tostring(slot))
if s then
box:attr("data-hero-module", tostring(slot))
for _, v in ipairs(s) do
 
if v ~= "—" then
if extraClasses then
return {
if type(extraClasses) == "string" then
durationBlock = app.Duration,
box:addClass(extraClasses)
suppressDurationIndex = idx,
elseif type(extraClasses) == "table" then
}
for _, c in ipairs(extraClasses) do
end
box:addClass(c)
end
end
end
end
end
end
end


if isEmpty then
return nil
box:addClass("hero-module-empty")
end
end


local body = box:tag("div"):addClass("hero-module-body")
----------------------------------------------------------------------
if innerHtml and innerHtml ~= "" then
-- Plug-ins
body:wikitext(innerHtml)
----------------------------------------------------------------------
end
 
local PLUGINS = {}


return tostring(box)
-- PLUGIN: IconName (Hero Bar Slot 1) - icon + name.
end
function PLUGINS.IconName(rec, ctx)
local icon  = rec.Icon
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"


----------------------------------------------------------------------
local wrap = mw.html.create("div")
-- Shared helpers used by plug-ins (Source + QuickStats)
wrap:addClass("sv-herobar-1-wrap")
----------------------------------------------------------------------


local function formatScalingCompactLines(scaling)
if icon and icon ~= "" then
if type(scaling) ~= "table" then
wrap:tag("div")
return {}
:addClass("sv-herobar-icon")
:wikitext(string.format("[[File:%s|80px|link=]]", icon))
end
end


local list = scaling
wrap:tag("div")
if #list == 0 then
:addClass("spiritvale-infobox-title")
if scaling.Percent ~= nil or scaling["Scaling ID"] or scaling["Scaling Name"] then
:wikitext(title)
list = { scaling }
else
return {}
end
end


local out = {}
return {
for _, s in ipairs(list) do
inner = tostring(wrap),
if type(s) == "table" then
classes = "module-herobar-1",
local stat = s["Scaling Name"] or s["Scaling ID"] or "Unknown"
}
local pct  = s.Percent
end
local pctN = toNum(pct)


if pctN ~= nil and pctN ~= 0 then
-- PLUGIN: SkillType (Hero Bar Slot 2) - 2 rows x 3 cells (desktop + mobile).
table.insert(out, string.format("%s%% %s", fmtNum(pctN), stat))
-- Rules:
elseif pct ~= nil and tostring(pct) ~= "" and tostring(pct) ~= "0" then
--  - If skill is non-damaging, hide Damage/Element/Hits.
table.insert(out, string.format("%s%% %s", tostring(pct), stat))
--  - If Hits is empty, hide Hits.
end
--  - If Combo is empty, hide Combo.
end
-- Ordering:
end
--  - Desktop: Damage, Element, Hits, Target, Cast, Combo
--  - Mobile:  Damage, Element, Target, Cast, Hits, Combo (CSS reorder)
function PLUGINS.SkillType(rec, ctx)
local typeBlock = (type(rec.Type) == "table") and rec.Type or {}
local mech      = (type(rec.Mechanics) == "table") and rec.Mechanics or {}


return out
local level    = ctx.level or 1
end
local maxLevel = ctx.maxLevel or 1


local function basisWordFromFlags(atkFlag, matkFlag)
local hideDamageBundle = (ctx.nonDamaging == true)
if atkFlag and matkFlag then
return "Hybrid"
elseif atkFlag then
return "Attack"
elseif matkFlag then
return "Magic Attack"
end
return nil
end


local function sourceValueForLevel(src, maxLevel, level)
-- valName: extract a display string from typical {Name/ID/Value} objects.
if type(src) ~= "table" then
-- NOTE: Includes number support so Hits=2 (number) doesn't get dropped.
local function valName(x)
if x == nil then return nil end
if type(x) == "table" then
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.Value ~= nil then return tostring(x.Value) end
end
if type(x) == "number" then
return tostring(x)
end
if type(x) == "string" and x ~= "" then
return x
end
return nil
return nil
end
end


local per = src["Per Level"]
-- hitsDisplay: find + render Hits from multiple possible structured locations.
if type(per) == "table" and #per > 0 then
local function hitsDisplay()
if isFlatList(per) then
local effects = (type(mech.Effects) == "table") and mech.Effects or {}
local one  = formatUnitValue(per[1]) or tostring(per[1])
 
local show = formatUnitValue(src.Base) or one
local h =
return show and mw.text.nowiki(show) or nil
typeBlock.Hits or typeBlock["Hits"] or typeBlock["Hit Count"] or typeBlock["Hits Count"] or
mech.Hits or mech["Hits"] or mech["Hit Count"] or mech["Hits Count"] or
effects.Hits or effects["Hits"] or effects["Hit Count"] or effects["Hits Count"] or
rec.Hits or rec["Hits"]
 
if h == nil or isNoneLike(h) then
return nil
end
end


local series = {}
-- ValuePair-style table (Base/Per Level) => dynamic series
for _, v in ipairs(per) do
if type(h) == "table" then
table.insert(series, formatUnitValue(v) or tostring(v))
if h.Base ~= nil or h["Per Level"] ~= nil or type(h["Per Level"]) == "table" then
return displayFromSeries(seriesFromValuePair(h, maxLevel), level)
end
 
-- Unit block {Value, Unit}
if h.Value ~= nil then
local t = formatUnitValue(h)
return t and mw.text.nowiki(t) or nil
end
 
-- Fallback name extraction
local function valName(x)
if x == nil then return nil end
if type(x) == "table" then
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.Value ~= nil then return tostring(x.Value) end
end
if type(x) == "number" then return tostring(x) end
if type(x) == "string" and x ~= "" then return x end
return nil
end
 
local vn = valName(h)
if vn and not isNoneLike(vn) then
return mw.text.nowiki(vn)
end
end
end
return dynSpan(series, level)
end


return valuePairDynamicValueOnly(src, maxLevel, level)
-- Scalar number/string
end
if type(h) == "number" then
return mw.text.nowiki(fmtNum(h))
end
if type(h) == "string" then
local t = trim(h)
return (t and not isNoneLike(t)) and mw.text.nowiki(t) or nil
end


local function legacyPercentAtLevel(entry, level)
if type(entry) ~= "table" then
return nil
return nil
end
end


local baseRaw = entry["Base %"]
-- comboDisplay: render Combo as a compact text block (Type (+ details)).
local perRaw  = entry["Per Level %"]
local function comboDisplay()
local baseN  = toNum(baseRaw)
local c = (type(mech.Combo) == "table") and mech.Combo or nil
local perN    = toNum(perRaw)
if not c then return nil end


if perN ~= nil and perN ~= 0 then
local typ = trim(c.Type)
local total = (baseN or 0) + (perN * level)
if not typ or isNoneLike(typ) then
return fmtNum(total) .. "%"
return nil
end
end
 
local details = {}


if baseN ~= nil then
local pct = formatUnitValue(c.Percent)
return fmtNum(baseN) .. "%"
if pct and not isZeroish(pct) then
end
table.insert(details, mw.text.nowiki(pct))
if baseRaw ~= nil and tostring(baseRaw) ~= "" then
end
return tostring(baseRaw) .. "%"
end


return nil
local dur = formatUnitValue(c.Duration)
end
if dur and not isZeroish(dur) then
table.insert(details, mw.text.nowiki(dur))
end


local function seriesFromValuePair(block, maxLevel)
if #details > 0 then
if type(block) ~= "table" then
return mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")"
return nil
end
return mw.text.nowiki(typ)
end
end


local base = block.Base
local grid = mw.html.create("div")
local per  = block["Per Level"]
grid:addClass("sv-type-grid")
grid:addClass("sv-compact-root")


local function pickUnit(v)
local added = false
if type(v) == "table" and v.Unit and v.Unit ~= "" then
 
return v.Unit
-- addChunk: add one labeled value cell (key drives CSS ordering).
end
local function addChunk(key, label, valueHtml)
return nil
if valueHtml == nil or valueHtml == "" then return end
end
added = true
local unit = pickUnit(base) or pickUnit(per)
 
local chunk = grid:tag("div")
:addClass("sv-type-chunk")
:addClass("sv-type-" .. tostring(key))
:attr("data-type-key", tostring(key))
 
chunk:tag("div")
:addClass("sv-type-label")
:wikitext(mw.text.nowiki(label))


local function fmtAny(v)
chunk:tag("div")
local t = formatUnitValue(v)
:addClass("sv-type-value")
return t and tostring(t) or (v ~= nil and tostring(v) or nil)
:wikitext(valueHtml)
end
end


local series = {}
-- Damage + Element + Hits bundle (hidden when non-damaging)
if not hideDamageBundle then
local dmg  = valName(typeBlock.Damage or typeBlock["Damage Type"])
local ele  = valName(typeBlock.Element or typeBlock["Element Type"])
local hits = hitsDisplay()


-- Preferred: expanded series (wikiprep)
if dmg and not isNoneLike(dmg) then
if type(per) == "table" and #per > 0 then
addChunk("damage", "Damage", mw.text.nowiki(dmg))
for lv = 1, maxLevel do
end
local raw = per[lv] or per[#per]
if ele and not isNoneLike(ele) then
local one = fmtAny(raw)
addChunk("element", "Element", mw.text.nowiki(ele))
if one == nil or isZeroish(raw) or isZeroish(one) then
end
one = ""
if hits then
end
addChunk("hits", "Hits", hits)
series[lv] = one
end
end
return series
end
end


-- Empty per-list -> base only
-- Target + Cast
if type(per) == "table" and #per == 0 then
local tgt = valName(typeBlock.Target or typeBlock["Target Type"])
local one = fmtAny(base)
local cst = valName(typeBlock.Cast  or typeBlock["Cast Type"])
if one == nil or isZeroish(base) or isZeroish(one) then
 
one = ""
if tgt and not isNoneLike(tgt) then
end
addChunk("target", "Target", mw.text.nowiki(tgt))
for lv = 1, maxLevel do
end
series[lv] = one
if cst and not isNoneLike(cst) then
end
addChunk("cast", "Cast", mw.text.nowiki(cst))
return series
end
end


-- Scalar per -> compute base + per*level (fallback)
-- Combo
local baseN = toNum(base) or 0
local combo = comboDisplay()
local perN  = toNum(per)
if combo then
addChunk("combo", "Combo", combo)
end


if perN ~= nil then
return {
for lv = 1, maxLevel do
inner = added and tostring(grid) or "",
local total = baseN + (perN * lv)
classes = "module-skill-type",
local v = unit and { Value = total, Unit = unit } or total
}
local one = fmtAny(v)
end
if one == nil or total == 0 or isZeroish(one) then
 
one = "—"
-- PLUGIN: SourceType (Hero Module Slot 1) - Modifier + Source + Scaling.
function PLUGINS.SourceType(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
 
local basisWord = nil
local sourceKind = nil
local sourceVal  = nil
local scaling    = nil
 
-- sourceValueForLevel: dynamic formatting for structured Source blocks.
local function sourceValueForLevel(src)
if type(src) ~= "table" then
return nil
end
 
local per = src["Per Level"]
if type(per) == "table" and #per > 0 then
if isFlatList(per) then
local one = formatUnitValue(per[1]) or tostring(per[1])
local show = formatUnitValue(src.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
end
series[lv] = one
return dynSpan(series, level)
end
end
return series
 
return valuePairDynamicValueOnly(src, maxLevel, level)
end
end


-- Base-only scalar
if type(rec.Source) == "table" then
local raw = (base ~= nil) and base or per
local src = rec.Source
local one = fmtAny(raw)
local atkFlag  = (src["ATK-Based"] == true)
if one == nil then
local matkFlag = (src["MATK-Based"] == true)
return nil
basisWord = basisWordFromFlags(atkFlag, matkFlag)
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)
sourceKind = src.Type or ((src.Healing == true) and "Healing") or "Damage"
if type(series) ~= "table" or #series == 0 then
sourceVal  = sourceValueForLevel(src)
return nil
scaling    = src.Scaling
end
end


local any = false
-- Fallback to legacy Damage lists if Source absent
for _, v in ipairs(series) do
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
if v ~= "" then
local dmg = rec.Damage
any = true
scaling = scaling or dmg.Scaling
break
end
end
if not any then
return nil
end


if isFlatList(series) then
local main = dmg["Main Damage"]
return mw.text.nowiki(series[1])
local refl = dmg["Reflect Damage"]
end
local flat = dmg["Flat Damage"]
return dynSpan(series, level)
end


local function formatAreaSize(area)
if type(main) == "table" and #main > 0 then
if type(area) ~= "table" then
local pick = nil
return nil
for _, d in ipairs(main) do
end
if type(d) == "table" and d.Type ~= "Healing" then
pick = d
break
end
end
pick = pick or main[1]


local raw = area["Area Size"]
if type(pick) == "table" then
if raw == nil then
local atkFlag  = (pick["ATK-Based"] == true)
return nil
local matkFlag = (pick["MATK-Based"] == true)
end
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)


local name, num
sourceKind = (pick.Type == "Healing") and "Healing" or "Damage"
sourceVal  = legacyPercentAtLevel(pick, level)
end
elseif type(refl) == "table" and #refl > 0 and type(refl[1]) == "table" then
local pick = refl[1]
local atkFlag  = (pick["ATK-Based"] == true)
local matkFlag = (pick["MATK-Based"] == true)
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)


if type(raw) == "table" then
sourceKind = "Reflect"
name = raw.Name or raw.ID or raw.Value
sourceVal = legacyPercentAtLevel(pick, level)
num = raw.Value
elseif type(flat) == "table" and #flat > 0 and type(flat[1]) == "table" then
if raw.Name or raw.ID then
local pick = flat[1]
name = raw.Name or raw.ID
local atkFlag  = (pick["ATK-Based"] == true)
end
local matkFlag = (pick["MATK-Based"] == true)
elseif type(raw) == "string" then
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)
name = raw
elseif type(raw) == "number" then
num = raw
end
 
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
sourceKind = "Flat"
local s = mw.text.trim(tostring(name))
sourceVal  = legacyPercentAtLevel(pick, level)
if s == "" or isNoneLike(s) then
return nil
end
if mw.ustring.find(s, "%(") then
return mw.text.nowiki(s)
end
if num ~= nil and num ~= 0 then
return mw.text.nowiki(string.format("%s (%s)", s, fmtNum(num)))
end
end
return mw.text.nowiki(s)
end
end


if num ~= nil and num ~= 0 then
local scalingLines = formatScalingCompactLines(scaling)
return mw.text.nowiki(string.format("(%s)", fmtNum(num)))
local hasSource    = (sourceVal ~= nil and tostring(sourceVal) ~= "")
local hasScaling  = (type(scalingLines) == "table" and #scalingLines > 0)
 
if (not hasSource) and (not hasScaling) then
return nil
end
end


return nil
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")
end
 
local extra = { "skill-source-module" }
table.insert(extra, hasMod and "sv-has-mod" or "sv-no-mod")


local function skillHasAnyDamage(rec, maxLevel)
if hasSource and (not hasScaling) then
if type(rec.Source) == "table" then
table.insert(extra, "sv-only-source")
local s = seriesFromValuePair(rec.Source, maxLevel)
elseif hasScaling and (not hasSource) then
if s then
table.insert(extra, "sv-only-scaling")
for _, v in ipairs(s) do
if v ~= "" then return true end
end
end
end
end


if type(rec.Damage) == "table" then
local wrap = mw.html.create("div")
local dmg = rec.Damage
wrap:addClass("sv-source-grid")
for _, key in ipairs({ "Main Damage", "Flat Damage", "Reflect Damage" }) do
wrap:addClass("sv-compact-root")
local lst = dmg[key]
 
if type(lst) == "table" and #lst > 0 then
if hasMod then
return true
local modCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-modifier")
end
modCol:tag("div"):addClass("sv-source-pill"):wikitext("Modifier")
end
modCol:tag("div"):addClass("sv-modifier-value"):wikitext(mw.text.nowiki(basisWord))
end
end


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


local function computeDurationPromotion(rec, maxLevel)
if hasScaling then
-- Only:
local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling")
-- - non-damaging skills
scalingCol:tag("div"):addClass("sv-source-pill"):wikitext("Scaling")
-- - AND missing/blank Duration in Basic Timings
-- - AND a Status Application has a Duration block
-- Then: promote that status duration into QuickStats 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 list = scalingCol:tag("div"):addClass("sv-scaling-list")
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
for _, line in ipairs(scalingLines) do
local durS = seriesFromValuePair(bt["Duration"], maxLevel)
list:tag("div"):addClass("sv-scaling-item"):wikitext(mw.text.nowiki(line))
 
if durS ~= nil then
local any = false
for _, v in ipairs(durS) do
if v ~= "" then any = true break end
end
if any then
return nil
end
end
end
end
-- if durS is nil OR all "—", allow promotion


local apps = rec["Status Applications"]
return {
if type(apps) ~= "table" then return nil end
inner = tostring(wrap),
 
classes = extra,
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


----------------------------------------------------------------------
-- PLUGIN: QuickStats (Hero Module Slot 2) - 3x2 grid (range/area/cost/cast/cd/duration).
-- Plug-ins (name -> { inner = "...", classes = "..."/{...} } OR nil/"")
-- NOTE: Hits does NOT live here (it lives in SkillType).
----------------------------------------------------------------------
function PLUGINS.QuickStats(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
local promo = ctx.promo


local PLUGINS = {}
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 {}


-- Hero Bar Slot 1: Icon + Name
local function dash() return "" end
function PLUGINS.IconName(rec, ctx)
local icon  = rec.Icon
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"


local wrap = mw.html.create("div")
-- Range (0 => —)
wrap:addClass("sv-herobar-1-wrap")
local rangeVal = nil
 
if mech.Range ~= nil and not isNoneLike(mech.Range) then
if icon and icon ~= "" then
local n = toNum(mech.Range)
wrap:tag("div")
if n ~= nil then
:addClass("sv-herobar-icon")
if n ~= 0 then
:wikitext(string.format("[[File:%s|80px|link=]]", icon))
rangeVal = mw.text.nowiki(formatUnitValue(mech.Range) or tostring(mech.Range))
end
end
else
local t = mw.text.trim(tostring(mech.Range))
if t ~= "" and not isNoneLike(t) then
rangeVal = mw.text.nowiki(t)
end
end
end


wrap:tag("div")
-- Area
:addClass("spiritvale-infobox-title")
local areaVal = formatAreaSize(mech.Area, maxLevel, level)
:wikitext(title)


return {
-- Timings
inner = tostring(wrap),
local castVal = displayFromSeries(seriesFromValuePair(bt["Cast Time"], maxLevel), level)
classes = "module-herobar-1",
local cdVal  = displayFromSeries(seriesFromValuePair(bt["Cooldown"],  maxLevel), level)
}
local durVal  = displayFromSeries(seriesFromValuePair(bt["Duration"],  maxLevel), level)
end


-- Hero Bar Slot 2: Reserved container (kept wired for future use)
-- Promote status duration if needed
function PLUGINS.ReservedInfo(rec, ctx)
if (durVal == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then
local wrap = mw.html.create("div")
durVal = displayFromSeries(seriesFromValuePair(promo.durationBlock, maxLevel), level)
wrap:addClass("sv-herobar-2-wrap")
end
return {
inner = tostring(wrap),
classes = "module-herobar-2",
}
end


-- Module Slot 1: Level Selector
-- Cost: MP + HP
function PLUGINS.LevelSelector(rec, ctx)
local function labeledSeries(block, label)
local level = ctx.level or 1
local s = seriesFromValuePair(block, maxLevel)
local maxLevel = ctx.maxLevel or 1
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
return any and s or nil
end


local inner = mw.html.create("div")
local mpS = labeledSeries(rc["Mana Cost"], "MP")
inner:addClass("sv-level-ui")
local hpS = labeledSeries(rc["Health Cost"], "HP")


inner:tag("div")
local costSeries = {}
:addClass("sv-level-label")
for lv = 1, maxLevel do
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
local mp = mpS and mpS[lv] or ""
local hp = hpS and hpS[lv] or ""


local slider = inner:tag("div"):addClass("sv-level-slider")
if mp ~= "—" and hp ~= "—" then
costSeries[lv] = mp .. " + " .. hp
elseif mp ~= "—" then
costSeries[lv] = mp
elseif hp ~= "" then
costSeries[lv] = hp
else
costSeries[lv] = ""
end
end


if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
local costVal = displayFromSeries(costSeries, level)
slider:tag("input")
:attr("type", "range")
:attr("min", "1")
:attr("max", tostring(maxLevel))
:attr("value", tostring(level))
:addClass("sv-level-range")
:attr("aria-label", "Skill level select")
else
inner:addClass("sv-level-ui-single")
slider:addClass("sv-level-slider-single")
end


return {
local grid = mw.html.create("div")
inner = tostring(inner),
grid:addClass("sv-m4-grid")
classes = "module-level-selector",
grid:addClass("sv-compact-root")
 
local function addCell(label, val)
local cell = grid:tag("div"):addClass("sv-m4-cell")
cell:tag("div"):addClass("sv-m4-label"):wikitext(mw.text.nowiki(label))
cell:tag("div"):addClass("sv-m4-value"):wikitext(val or dash())
end
 
addCell("Range",    rangeVal)
addCell("Area",      areaVal)
addCell("Cost",      costVal)
addCell("Cast Time", castVal)
addCell("Cooldown",  cdVal)
addCell("Duration",  durVal)
 
return {
inner = tostring(grid),
classes = "module-quick-stats",
}
}
end
end


-- Module Slot 2: Skill Type
-- PLUGIN: SpecialMechanics (Hero Module Slot 3)
function PLUGINS.SkillType(rec, ctx)
-- Shows:
local typeBlock = (type(rec.Type) == "table") and rec.Type or {}
--  - Flags (deduped)
local hideDamageAndElement = (ctx.nonDamaging == true)
--  - Special mechanics (mech.Effects)
-- NOTE: Combo lives in SkillType (Hero Bar Slot 2).
function PLUGINS.SpecialMechanics(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
 
local mech    = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
local effects = (type(mech.Effects) == "table") and mech.Effects or nil
local mods    = (type(rec.Modifiers) == "table") and rec.Modifiers or nil


local function valName(x)
------------------------------------------------------------------
if x == nil then return nil end
-- Hits guard (we want Hits ONLY in SkillType)
if type(x) == "table" then
------------------------------------------------------------------
if x.Name and x.Name ~= "" then return tostring(x.Name) end
local function isHitsKey(name)
if x.ID and x.ID ~= "" then return tostring(x.ID) end
if not name then return false end
if x.Value ~= nil then return tostring(x.Value) end
local k = mw.ustring.lower(mw.text.trim(tostring(name)))
end
return (
if type(x) == "string" and x ~= "" then
k == "hit" or
return x
k == "hits" or
end
k == "hit count" or
return nil
k == "hits count" or
k == "hitcount" or
k == "hitscount"
)
end
end


local grid = mw.html.create("div")
------------------------------------------------------------------
grid:addClass("sv-type-grid")
-- Flags (flat, de-duped)
------------------------------------------------------------------
local flagSet = {}
 
local denyFlags = {
["self centered"] = true,
["self-centred"] = true,
["bond"] = true,
["combo"] = true,
        ["hybrid"] = true,
 
-- hits variants
["hit"] = true,
["hits"] = true,
["hit count"] = true,
["hits count"] = true,
["hitcount"] = true,
["hitscount"] = true,
}


local added = false
local function allowFlag(name)
local function addChunk(label, rawVal)
if not name then return false end
local v = valName(rawVal)
local k = mw.ustring.lower(mw.text.trim(tostring(name)))
if not v or v == "" then return end
if k == "" then return false end
added = true
if denyFlags[k] then return false end
return true
end


local chunk = grid:tag("div"):addClass("sv-type-chunk")
local function addFlags(sub)
chunk:tag("div"):addClass("sv-type-label"):wikitext(mw.text.nowiki(label))
if type(sub) ~= "table" then return end
chunk:tag("div"):addClass("sv-type-value"):wikitext(mw.text.nowiki(v))
for k, v in pairs(sub) do
if v and allowFlag(k) then
flagSet[tostring(k)] = true
end
end
end
end


if not hideDamageAndElement then
if mods then
addChunk("Damage",  typeBlock.Damage  or typeBlock["Damage Type"])
addFlags(mods["Movement Modifiers"])
addChunk("Element", typeBlock.Element or typeBlock["Element Type"])
addFlags(mods["Combat Modifiers"])
addFlags(mods["Special Modifiers"])
for k, v in pairs(mods) do
if type(v) == "boolean" and v and allowFlag(k) then
flagSet[tostring(k)] = true
end
end
end
end


addChunk("Target", typeBlock.Target or typeBlock["Target Type"])
local flags = {}
addChunk("Cast",  typeBlock.Cast  or typeBlock["Cast Type"])
for k, _ in pairs(flagSet) do table.insert(flags, k) end
table.sort(flags)


return {
------------------------------------------------------------------
inner = added and tostring(grid) or "",
-- Special mechanics (name => value)
classes = "module-skill-type",
------------------------------------------------------------------
}
local mechItems = {}
end
 
if effects then
local keys = {}
for k, _ in pairs(effects) do table.insert(keys, k) end
table.sort(keys)


-- Module Slot 3: SourceType (Modifier + Source + Scaling) OR nil for blank
for _, name in ipairs(keys) do
function PLUGINS.SourceType(rec, ctx)
-- Skip Hits completely (it belongs in SkillType)
local level = ctx.level or 1
if not isHitsKey(name) then
local maxLevel = ctx.maxLevel or 1
local block = effects[name]
if type(block) == "table" then
-- Also skip if the block's Type is "Hits" (some data may encode it that way)
if not isHitsKey(block.Type) then
local disp = displayFromSeries(seriesFromValuePair(block, maxLevel), level)
local t = trim(block.Type)


local basisWord = nil
local value = disp
local sourceKind = nil
local sourceVal  = nil
local scaling    = nil


if type(rec.Source) == "table" then
-- If Type exists and is distinct, prefix it.
local src = rec.Source
if t and not isNoneLike(t) and mw.ustring.lower(t) ~= mw.ustring.lower(tostring(name)) then
local atkFlag  = (src["ATK-Based"] == true)
if value then
local matkFlag = (src["MATK-Based"] == true)
value = mw.text.nowiki(t) .. ": " .. value
basisWord = basisWordFromFlags(atkFlag, matkFlag)
else
value = mw.text.nowiki(t)
end
end


sourceKind = src.Type or ((src.Healing == true) and "Healing") or "Damage"
if value then
sourceVal  = sourceValueForLevel(src, maxLevel, level)
table.insert(mechItems, { label = tostring(name), value = value })
scaling    = src.Scaling
end
end
end
end
end
end
end


-- Fallback to legacy Damage lists if Source absent
local hasFlags = (#flags > 0)
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
local hasMech  = (#mechItems > 0)
local dmg = rec.Damage
scaling = scaling or dmg.Scaling


local main = dmg["Main Damage"]
if (not hasFlags) and (not hasMech) then
local refl = dmg["Reflect Damage"]
local root = mw.html.create("div")
local flat = dmg["Flat Damage"]
root:addClass("sv-sm-root")
root:addClass("sv-compact-root")
root:tag("div"):addClass("sv-sm-empty"):wikitext("No Special Mechanics")


if type(main) == "table" and #main > 0 then
return {
local pick = nil
inner = tostring(root),
for _, d in ipairs(main) do
classes = "module-special-mechanics",
if type(d) == "table" and d.Type ~= "Healing" then
}
pick = d
end
break
end
end
pick = pick or main[1]


if type(pick) == "table" then
local count = 0
local atkFlag  = (pick["ATK-Based"] == true)
if hasFlags then count = count + 1 end
local matkFlag = (pick["MATK-Based"] == true)
if hasMech  then count = count + 1 end
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)


sourceKind = (pick.Type == "Healing") and "Healing" or "Damage"
local root = mw.html.create("div")
sourceVal  = legacyPercentAtLevel(pick, level)
root:addClass("sv-sm-root")
end
root:addClass("sv-compact-root")
elseif type(refl) == "table" and #refl > 0 and type(refl[1]) == "table" then
local pick = refl[1]
local atkFlag  = (pick["ATK-Based"] == true)
local matkFlag = (pick["MATK-Based"] == true)
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)


sourceKind = "Reflect"
local layout = root:tag("div"):addClass("sv-sm-layout")
sourceVal  = legacyPercentAtLevel(pick, level)
layout:addClass("sv-sm-count-" .. tostring(count))
elseif type(flat) == "table" and #flat > 0 and type(flat[1]) == "table" then
local pick = flat[1]
local atkFlag  = (pick["ATK-Based"] == true)
local matkFlag = (pick["MATK-Based"] == true)
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)


sourceKind = "Flat"
-- Column 1: Flags
sourceVal  = legacyPercentAtLevel(pick, level)
if hasFlags then
local fcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-flags")
for _, f in ipairs(flags) do
fcol:tag("div"):addClass("sv-sm-flag"):wikitext(mw.text.nowiki(f))
end
end
end
end


local scalingLines = formatScalingCompactLines(scaling)
-- Column 2: Special Mechanics (stacked)
local hasSource    = (sourceVal ~= nil and tostring(sourceVal) ~= "")
if hasMech then
local hasScaling  = (type(scalingLines) == "table" and #scalingLines > 0)
local mcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-mech")
 
for _, it in ipairs(mechItems) do
if (not hasSource) and (not hasScaling) then
local one = mcol:tag("div"):addClass("sv-sm-mech")
return nil
one:tag("div"):addClass("sv-sm-label"):wikitext(mw.text.nowiki(it.label))
one:tag("div"):addClass("sv-sm-value"):wikitext(it.value or "—")
end
end
end


local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")
return {
inner = tostring(root),
classes = "module-special-mechanics",
}
end


local extra = { "skill-source-module" }
-- PLUGIN: LevelSelector (Hero Module Slot 4) - JS level slider.
table.insert(extra, hasMod and "sv-has-mod" or "sv-no-mod")
function PLUGINS.LevelSelector(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1


if hasSource and (not hasScaling) then
local inner = mw.html.create("div")
table.insert(extra, "sv-only-source")
inner:addClass("sv-level-ui")
elseif hasScaling and (not hasSource) then
table.insert(extra, "sv-only-scaling")
end


local wrap = mw.html.create("div")
inner:tag("div")
wrap:addClass("sv-source-grid")
:addClass("sv-level-label")
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))


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


if hasSource then
if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
local sourceCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-main")
slider:tag("input")
sourceCol:tag("div"):addClass("sv-source-pill"):wikitext(mw.text.nowiki(sourceKind or "Source"))
:attr("type", "range")
sourceCol:tag("div"):addClass("sv-source-value"):wikitext(sourceVal)
:attr("min", "1")
end
:attr("max", tostring(maxLevel))
 
:attr("value", tostring(level))
if hasScaling then
:addClass("sv-level-range")
local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling")
:attr("aria-label", "Skill level select")
scalingCol:tag("div"):addClass("sv-source-pill"):wikitext("Scaling")
else
 
inner:addClass("sv-level-ui-single")
local list = scalingCol:tag("div"):addClass("sv-scaling-list")
slider:addClass("sv-level-slider-single")
for _, line in ipairs(scalingLines) do
list:tag("div"):addClass("sv-scaling-item"):wikitext(mw.text.nowiki(line))
end
end
end


return {
return {
inner = tostring(wrap),
inner = tostring(inner),
classes = extra,
classes = "module-level-selector",
}
}
end
end


-- Module Slot 4: Quick Stats (3x2)
----------------------------------------------------------------------
function PLUGINS.QuickStats(rec, ctx)
-- Generic slot renderers
local level = ctx.level or 1
----------------------------------------------------------------------
local maxLevel = ctx.maxLevel or 1
local promo = ctx.promo


local mech = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
-- normalizeResult: normalize plugin return values into {inner, classes}.
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
local function normalizeResult(res)
local rc  = (type(mech["Resource Cost"]) == "table") and mech["Resource Cost"] or {}
if res == nil then return nil end
if type(res) == "string" then
return { inner = res, classes = nil }
end
if type(res) == "table" then
local inner = res.inner
if type(inner) ~= "string" then
inner = (inner ~= nil) and tostring(inner) or ""
end
return { inner = inner, classes = res.classes }
end
return { inner = tostring(res), classes = nil }
end


local function dash() return "—" end
-- safeCallPlugin: pcall wrapper to prevent infobox failure on plugin errors.
 
local function safeCallPlugin(name, rec, ctx)
-- Range (0 => —)
local fn = PLUGINS[name]
local rangeVal = nil
if type(fn) ~= "function" then
if mech.Range ~= nil and not isNoneLike(mech.Range) then
return nil
local n = toNum(mech.Range)
end
if n ~= nil then
local ok, out = pcall(fn, rec, ctx)
if n ~= 0 then
if not ok then
rangeVal = mw.text.nowiki(formatUnitValue(mech.Range) or tostring(mech.Range))
return nil
end
else
local t = mw.text.trim(tostring(mech.Range))
if t ~= "" and not isNoneLike(t) then
rangeVal = mw.text.nowiki(t)
end
end
end
end
return normalizeResult(out)
end


-- Area
-- renderHeroBarSlot: render a hero-bar slot by plugin assignment.
local areaVal = formatAreaSize(mech.Area)
local function renderHeroBarSlot(slotIndex, rec, ctx)
        local pluginName = HERO_BAR_SLOT_ASSIGNMENT[slotIndex]
        if not pluginName then
                return heroBarBox(slotIndex, nil, "", true)
        end
 
        local res = safeCallPlugin(pluginName, rec, ctx)
        if not res or not res.inner or res.inner == "" then
                return heroBarBox(slotIndex, nil, "", true)
        end


-- Timings
        return heroBarBox(slotIndex, res.classes, res.inner, false)
local castSeries = seriesFromValuePair(bt["Cast Time"], maxLevel)
end
local cdSeries  = seriesFromValuePair(bt["Cooldown"], maxLevel)
local durSeries  = seriesFromValuePair(bt["Duration"],  maxLevel)


local castVal = displayFromSeries(castSeries, level)
-- isEmptyModuleContent: true when a module slot has no meaningful content.
local cdVal  = displayFromSeries(cdSeries, level)
local function isEmptyModuleContent(inner)
local durVal  = displayFromSeries(durSeries, level)
        if inner == nil then return true end


-- Promote status duration if needed
        local trimmed = mw.text.trim(tostring(inner))
if (durVal == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then
        if trimmed == "" or trimmed == "" then
durSeries = seriesFromValuePair(promo.durationBlock, maxLevel)
                return true
durVal = displayFromSeries(durSeries, level)
        end
end


-- Cost: combine MP + HP; never show "0 HP" (seriesFromValuePair already turns 0 into "—")
        local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", ""))
local function labeledSeries(block, label)
        return (withoutTags == "" or withoutTags == "—")
local s = seriesFromValuePair(block, maxLevel)
end
if not s then return nil end
 
local any = false
-- mergeClasses: merge string/table classes with an optional extra class.
for i, v in ipairs(s) do
local function mergeClasses(base, extra)
if v ~= "" then
        local list = {}
s[i] = tostring(v) .. " " .. label
 
any = true
        local function add(item)
else
                if type(item) == "string" then
s[i] = ""
                        for cls in mw.ustring.gmatch(item, "[^%s]+") do
end
                                table.insert(list, cls)
end
                        end
return any and s or nil
                elseif type(item) == "table" then
end
                        for _, c in ipairs(item) do
                                add(c)
                        end
                end
        end


local mpS = labeledSeries(rc["Mana Cost"], "MP")
        add(base)
local hpS = labeledSeries(rc["Health Cost"], "HP")
        add(extra)


local costSeries = {}
        if #list == 0 then
for lv = 1, maxLevel do
                return nil
local mp = mpS and mpS[lv] or "—"
        elseif #list == 1 then
local hp = hpS and hpS[lv] or "—"
                return list[1]
        end


if mp ~= "—" and hp ~= "—" then
        return list
costSeries[lv] = mp .. " + " .. hp
end
elseif mp ~= "—" then
costSeries[lv] = mp
elseif hp ~= "—" then
costSeries[lv] = hp
else
costSeries[lv] = "—"
end
end


local costVal = displayFromSeries(costSeries, level)
-- renderModuleSlot: render a hero-module slot by plugin assignment.
local function renderModuleSlot(slotIndex, rec, ctx)
        local pluginName = HERO_MODULE_SLOT_ASSIGNMENT[slotIndex]
        if not pluginName then
                return nil
        end


local grid = mw.html.create("div")
        local res = safeCallPlugin(pluginName, rec, ctx)
grid:addClass("sv-m4-grid")
        if not res or isEmptyModuleContent(res.inner) then
                return nil
        end


local function addCell(label, val)
        return {
local cell = grid:tag("div"):addClass("sv-m4-cell")
                inner = res.inner,
cell:tag("div"):addClass("sv-m4-label"):wikitext(mw.text.nowiki(label))
                classes = res.classes,
cell:tag("div"):addClass("sv-m4-value"):wikitext(val or dash())
        }
end
end


addCell("Range",    rangeVal)
----------------------------------------------------------------------
addCell("Area",      areaVal)
-- UI builders
addCell("Cost",      costVal)
----------------------------------------------------------------------
addCell("Cast Time", castVal)
addCell("Cooldown",  cdVal)
addCell("Duration",  durVal)


return {
-- buildHeroBarUI: build the top hero bar (2 slots).
inner = tostring(grid),
local function buildHeroBarUI(rec, ctx)
classes = "module-quick-stats",
local bar = mw.html.create("div")
}
bar:addClass("hero-bar-grid")
bar:wikitext(renderHeroBarSlot(1, rec, ctx))
bar:wikitext(renderHeroBarSlot(2, rec, ctx))
return tostring(bar)
end
end


----------------------------------------------------------------------
-- buildHeroModulesUI: build the 2x2 module grid row (4 slots).
-- Generic slot renderers
local function buildHeroModulesUI(rec, ctx)
----------------------------------------------------------------------
        local grid = mw.html.create("div")
        grid:addClass("hero-modules-grid")
 
        local slots = {}
        for slot = 1, 4 do
                slots[slot] = renderModuleSlot(slot, rec, ctx)
        end
 
        local hasModules = false
        for _, pair in ipairs({ { 1, 2 }, { 3, 4 } }) do
                local left  = slots[pair[1]]
                local right = slots[pair[2]]
 
                if left or right then
                        if left and right then
                                grid:wikitext(moduleBox(pair[1], left.classes, left.inner, false))
                                grid:wikitext(moduleBox(pair[2], right.classes, right.inner, false))
                        elseif left then
                                grid:wikitext(moduleBox(pair[1], mergeClasses(left.classes, "hero-module-full"), left.inner, false))
                        elseif right then
                                grid:wikitext(moduleBox(pair[2], mergeClasses(right.classes, "hero-module-full"), right.inner, false))
                        end
 
                        hasModules = true
                end
        end
 
        if not hasModules then
                return ""
        end


local function normalizeResult(res)
        return tostring(grid)
if res == nil then return nil end
end
if type(res) == "string" then
return { inner = res, classes = nil }
end
if type(res) == "table" then
local inner = res.inner
if type(inner) ~= "string" then
inner = (inner ~= nil) and tostring(inner) or ""
end
return { inner = inner, classes = res.classes }
end
return { inner = tostring(res), classes = nil }
end


local function safeCallPlugin(name, rec, ctx)
-- addHeroModulesRow: add the hero-modules row into the infobox table.
local fn = PLUGINS[name]
local function addHeroModulesRow(tbl, modulesUI)
if type(fn) ~= "function" then
if not modulesUI or modulesUI == "" then
return nil
return
end
end
local ok, out = pcall(fn, rec, ctx)
if not ok then
return nil
end
return normalizeResult(out)
end


local function renderHeroBarSlot(slotIndex, rec, ctx)
local row = tbl:tag("tr")
local pluginName = HERO_BAR_SLOT_ASSIGNMENT[slotIndex]
row:addClass("hero-modules-row")
if not pluginName then
return heroBarBox(slotIndex, nil, "", true)
end


local res = safeCallPlugin(pluginName, rec, ctx)
local cell = row:tag("td")
if not res or not res.inner or res.inner == "" then
cell:attr("colspan", 2)
return heroBarBox(slotIndex, nil, "", true)
cell:addClass("hero-modules-cell")
end
cell:wikitext(modulesUI)
 
return heroBarBox(slotIndex, res.classes, res.inner, false)
end
 
local function renderModuleSlot(slotIndex, rec, ctx)
local pluginName = HERO_MODULE_SLOT_ASSIGNMENT[slotIndex]
if not pluginName then
return moduleBox(slotIndex, nil, "", true)
end
 
local res = safeCallPlugin(pluginName, rec, ctx)
if not res or not res.inner or res.inner == "" then
return moduleBox(slotIndex, nil, "", true)
end
 
return moduleBox(slotIndex, res.classes, res.inner, false)
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- UI builders (hero bar + modules grid)
-- Infobox builder
----------------------------------------------------------------------
----------------------------------------------------------------------


local function buildHeroBarUI(rec, ctx)
-- buildInfobox: render a single skill infobox.
local bar = mw.html.create("div")
local function buildInfobox(rec, opts)
bar:addClass("hero-bar-grid")
opts = opts or {}
local showUsers = (opts.showUsers ~= false)


bar:wikitext(renderHeroBarSlot(1, rec, ctx))
local maxLevel = tonumber(rec["Max Level"]) or 1
bar:wikitext(renderHeroBarSlot(2, rec, ctx))
if maxLevel < 1 then maxLevel = 1 end
local level = clamp(maxLevel, 1, maxLevel)


return tostring(bar)
local ctx = {
end
maxLevel = maxLevel,
level = level,
nonDamaging = false,
promo = nil,
}


local function buildHeroModulesUI(rec, ctx)
-- Non-damaging hides Damage/Element/Hits in SkillType
local grid = mw.html.create("div")
do
grid:addClass("hero-modules-grid")
local dmgVal = nil
 
if type(rec.Type) == "table" then
for slot = 1, 4 do
dmgVal = rec.Type.Damage or rec.Type["Damage Type"]
grid:wikitext(renderModuleSlot(slot, rec, ctx))
if type(dmgVal) == "table" then
dmgVal = dmgVal.Name or dmgVal.ID or dmgVal.Value
end
end
ctx.nonDamaging = isNoneLike(dmgVal) or (not skillHasAnyDamage(rec, maxLevel))
end
end


return tostring(grid)
ctx.promo = computeDurationPromotion(rec, maxLevel)
end


local function addHeroModulesRow(tbl, modulesUI)
local root = mw.html.create("table")
if not modulesUI or modulesUI == "" then
root:addClass("spiritvale-skill-infobox")
return
root:addClass("sv-skill-card")
root:attr("data-max-level", tostring(maxLevel))
root:attr("data-level", tostring(level))
 
if opts.inList then
root:addClass("sv-skill-inlist")
end
end


local row = tbl:tag("tr")
local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
row:addClass("hero-modules-row")
if internalId then
root:attr("data-skill-id", internalId)
end


local cell = row:tag("td")
local desc  = rec.Description or ""
cell:attr("colspan", 2)
cell:addClass("hero-modules-cell")
cell:wikitext(modulesUI)
end


----------------------------------------------------------------------
-- Hero Title Bar
-- Infobox builder
local heroRow = root:tag("tr")
----------------------------------------------------------------------
heroRow:addClass("spiritvale-infobox-main")
heroRow:addClass("sv-hero-title-row")
heroRow:addClass("hero-title-bar")


local function buildInfobox(rec, opts)
local heroCell = heroRow:tag("th")
opts = opts or {}
heroCell:attr("colspan", 2)
local showUsers = (opts.showUsers ~= false)
heroCell:addClass("sv-hero-title-cell")
heroCell:wikitext(buildHeroBarUI(rec, ctx))


local maxLevel = tonumber(rec["Max Level"]) or 1
-- Description Bar
if maxLevel < 1 then maxLevel = 1 end
if desc ~= "" then
local level = clamp(maxLevel, 1, maxLevel)
local descRow = root:tag("tr")
descRow:addClass("spiritvale-infobox-main")
descRow:addClass("sv-hero-desc-row")
descRow:addClass("hero-description-bar")


-- ctx passed to every plug-in (the only allowed “shared state”)
local descCell = descRow:tag("td")
local ctx = {
descCell:attr("colspan", 2)
maxLevel = maxLevel,
descCell:addClass("sv-hero-desc-cell")
level = level,
-- computed below:
nonDamaging = false,
promo = nil,
}


-- Determine “non-damaging” for SkillType (hide Damage/Element)
local descInner = descCell:tag("div")
do
descInner:addClass("spiritvale-infobox-main-right-inner")
local dmgVal = nil
 
if type(rec.Type) == "table" then
descInner:tag("div")
dmgVal = rec.Type.Damage or rec.Type["Damage Type"]
:addClass("spiritvale-infobox-description")
if type(dmgVal) == "table" then
:wikitext(string.format("''%s''", desc))
dmgVal = dmgVal.Name or dmgVal.ID or dmgVal.Value
end
end
ctx.nonDamaging = isNoneLike(dmgVal) or (not skillHasAnyDamage(rec, maxLevel))
end
end


-- Duration promotion (for QuickStats + Applies suppression)
-- Modules row
ctx.promo = computeDurationPromotion(rec, maxLevel)
addHeroModulesRow(root, buildHeroModulesUI(rec, ctx))


local root = mw.html.create("table")
-- Users (hide on direct skill page)
root:addClass("spiritvale-skill-infobox")
if showUsers then
root:addClass("sv-skill-card")
local users = rec.Users or {}
root:attr("data-max-level", tostring(maxLevel))
addRow(root, "Classes",  listToText(users.Classes)"sv-row-users", "Users.Classes")
root:attr("data-level", tostring(level))
addRow(root, "Summons",  listToText(users.Summons),  "sv-row-users", "Users.Summons")
 
addRow(root, "Monsters", listToText(users.Monsters), "sv-row-users", "Users.Monsters")
if opts.inList then
addRow(root, "Events",   listToText(users.Events)"sv-row-users", "Users.Events")
root:addClass("sv-skill-inlist")
end
end


local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
-- Requirements
if internalId then
local req = rec.Requirements or {}
root:attr("data-skill-id", internalId)
local hasReq =
end
(type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0) or
(type(req["Required Weapons"]) == "table" and #req["Required Weapons"] > 0) or
(type(req["Required Stances"]) == "table" and #req["Required Stances"] > 0)


local desc  = rec.Description or ""
if hasReq then
 
if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
-- Hero Title Bar
local skillParts = {}
local heroRow = root:tag("tr")
for _, rs in ipairs(req["Required Skills"]) do
heroRow:addClass("spiritvale-infobox-main")
local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or "Unknown"
heroRow:addClass("sv-hero-title-row")
local lvlReq  = rs["Required Level"]
heroRow:addClass("hero-title-bar")
if lvlReq then
table.insert(skillParts, string.format("%s (Lv.%s)", nameReq, lvlReq))
else
table.insert(skillParts, nameReq)
end
end
addRow(root, "Required Skills", table.concat(skillParts, ", "), "sv-row-req", "Requirements.Required Skills")
end


local heroCell = heroRow:tag("th")
addRow(root, "Required Weapons", listToText(req["Required Weapons"]), "sv-row-req", "Requirements.Required Weapons")
heroCell:attr("colspan", 2)
addRow(root, "Required Stances", listToText(req["Required Stances"]), "sv-row-req", "Requirements.Required Stances")
heroCell:addClass("sv-hero-title-cell")
end
heroCell:wikitext(buildHeroBarUI(rec, ctx))


-- Description Bar (special-cased for now)
-- Mechanics (keep small extras only)
if desc ~= "" then
local mech = rec.Mechanics or {}
local descRow = root:tag("tr")
if next(mech) ~= nil then
descRow:addClass("spiritvale-infobox-main")
if mech["Autocast Multiplier"] ~= nil then
descRow:addClass("sv-hero-desc-row")
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
descRow:addClass("hero-description-bar")
end
end


local descCell = descRow:tag("td")
-- Legacy damage breakdown (only when Source absent)
descCell:attr("colspan", 2)
if type(rec.Source) ~= "table" then
descCell:addClass("sv-hero-desc-cell")
local dmg = rec.Damage or {}
if next(dmg) ~= nil then
local main = dmg["Main Damage"]
local mainNonHeal, healOnly = {}, {}


local descInner = descCell:tag("div")
if type(main) == "table" then
descInner:addClass("spiritvale-infobox-main-right-inner")
for _, d in ipairs(main) do
if type(d) == "table" and d.Type == "Healing" then
table.insert(healOnly, d)
else
table.insert(mainNonHeal, d)
end
end
end


descInner:tag("div")
addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
:addClass("spiritvale-infobox-description")
addRow(root, "Flat Damage",    formatDamageList(dmg["Flat Damage"], maxLevel, level, false),        "sv-row-source", "Damage.Flat Damage")
:wikitext(string.format("''%s''", desc))
addRow(root, "Reflect Damage", formatDamageList(dmg["Reflect Damage"], maxLevel, level, false),    "sv-row-source", "Damage.Reflect Damage")
addRow(root, "Healing",       formatDamageList(healOnly, maxLevel, level, false),                  "sv-row-source", "Damage.Healing")
end
end
end


-- Modules row
-- Status rows
local modulesUI = buildHeroModulesUI(rec, ctx)
local function formatStatusApplications(list, suppressDurationIndex)
addHeroModulesRow(root, modulesUI)
if type(list) ~= "table" or #list == 0 then return nil end
 
local parts = {}
for idx, s in ipairs(list) do
if type(s) == "table" then
local typ  = s.Type or s.Scope or "Target"
local name = s["Status External Name"] or s["Status Internal Name"] or "Unknown status"
local seg = tostring(typ) .. " – " .. tostring(name)
local detail = {}


-- Users (hide on direct skill page)
if idx ~= suppressDurationIndex and type(s.Duration) == "table" then
if showUsers then
local t = valuePairDynamicValueOnly(s.Duration, maxLevel, level)
local users = rec.Users or {}
if t then table.insert(detail, "Duration: " .. t) end
addRow(root, "Classes",  listToText(users.Classes)"sv-row-users", "Users.Classes")
end
addRow(root, "Summons",  listToText(users.Summons),  "sv-row-users", "Users.Summons")
 
addRow(root, "Monsters", listToText(users.Monsters), "sv-row-users", "Users.Monsters")
if type(s.Chance) == "table" then
addRow(root, "Events",  listToText(users.Events),  "sv-row-users", "Users.Events")
local t = valuePairDynamicValueOnly(s.Chance, maxLevel, level)
if t then table.insert(detail, "Chance: " .. t) end
end
 
if #detail > 0 then
seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
end
 
table.insert(parts, seg)
end
end
 
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
end


-- Requirements
local function formatStatusRemoval(list)
local req = rec.Requirements or {}
if type(list) ~= "table" or #list == 0 then return nil end
local hasReq =
 
(type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0) or
local parts = {}
(type(req["Required Weapons"]) == "table" and #req["Required Weapons"] > 0) or
for _, r in ipairs(list) do
(type(req["Required Stances"]) == "table" and #req["Required Stances"] > 0)
if type(r) == "table" then
local names = r["Status External Name"]
local label


if hasReq then
if type(names) == "table" then
if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
label = table.concat(names, ", ")
local skillParts = {}
elseif type(names) == "string" then
for _, rs in ipairs(req["Required Skills"]) do
label = names
local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or "Unknown"
local lvlReq  = rs["Required Level"]
if lvlReq then
table.insert(skillParts, string.format("%s (Lv.%s)", nameReq, lvlReq))
else
else
table.insert(skillParts, nameReq)
label = "Status"
end
 
local amt = valuePairRawText(r)
amt = amt and mw.text.nowiki(amt) or nil
 
local seg = mw.text.nowiki(label)
if amt then
seg = seg .. " – " .. amt
end
end
table.insert(parts, seg)
end
end
addRow(root, "Required Skills", table.concat(skillParts, ", "), "sv-row-req", "Requirements.Required Skills")
end
end


addRow(root, "Required Weapons", listToText(req["Required Weapons"]), "sv-row-req", "Requirements.Required Weapons")
return (#parts > 0) and table.concat(parts, "<br />") or nil
addRow(root, "Required Stances", listToText(req["Required Stances"]), "sv-row-req", "Requirements.Required Stances")
end
end


-- Mechanics (detailed rows still shown; Range/Area/Timings/Cost are in QuickStats)
local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
local mech = rec.Mechanics or {}
local statusApps = formatStatusApplications(rec["Status Applications"], suppressIdx)
if next(mech) ~= nil then
local statusRem  = formatStatusRemoval(rec["Status Removal"])
if mech["Autocast Multiplier"] ~= nil then
if statusApps or statusRem then
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications")
end
addRow(root, "Removes", statusRem, "sv-row-status", "Status Removal")
 
addRow(root, "Combo",             formatCombo(mech.Combo), "sv-row-mech", "Mechanics.Combo")
addRow(root, "Special Mechanics", formatMechanicEffects(mech.Effects, maxLevel, level), "sv-row-mech", "Mechanics.Effects")
end
end


-- Source + Scaling are displayed in SourceType module now, so we do NOT repeat them as body rows.
-- Events
-- Keep the detailed legacy damage breakdown rows only (Main/Flat/Reflect/Healing) when Source missing.
local function formatEvents(list)
if type(rec.Source) ~= "table" then
if type(list) ~= "table" or #list == 0 then return nil end
local dmg = rec.Damage or {}
local parts = {}
if next(dmg) ~= nil then
for _, ev in ipairs(list) do
local main = dmg["Main Damage"]
if type(ev) == "table" then
local mainNonHeal, healOnly = {}, {}
local action = ev.Action or "On event"
 
local name  = ev["Skill Internal Name"] or ev["Skill External Name"] or "Unknown skill"
if type(main) == "table" then
table.insert(parts, string.format("%s → %s", action, name))
for _, d in ipairs(main) do
if type(d) == "table" and d.Type == "Healing" then
table.insert(healOnly, d)
else
table.insert(mainNonHeal, d)
end
end
end
end
local flatList = dmg["Flat Damage"]
local reflList = dmg["Reflect Damage"]
addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
addRow(root, "Flat Damage",    formatDamageList(flatList, maxLevel, level, false), "sv-row-source", "Damage.Flat Damage")
addRow(root, "Reflect Damage", formatDamageList(reflList, maxLevel, level, false), "sv-row-source", "Damage.Reflect Damage")
addRow(root, "Healing",        formatDamageList(healOnly, maxLevel, level, false), "sv-row-source", "Damage.Healing")
end
end
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
end


-- Modifiers flags
local eventsText = formatEvents(rec.Events)
local modsText = formatModifiers(rec.Modifiers)
if eventsText then
if modsText then
addRow(root, "Triggers", eventsText, "sv-row-meta", "Events")
addRow(root, "Flags", modsText, "sv-row-meta", "Modifiers")
end
end


-- Status (suppress promoted duration line if needed)
-- Notes
local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
if type(rec.Notes) == "table" and #rec.Notes > 0 then
local statusApps = formatStatusApplications(rec["Status Applications"], maxLevel, level, suppressIdx)
addRow(root, "Notes", table.concat(rec.Notes, "<br />"), "sv-row-meta", "Notes")
local statusRem  = formatStatusRemoval(rec["Status Removal"], maxLevel, level)
if statusApps or statusRem then
addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications")
addRow(root, "Removes", statusRem,  "sv-row-status", "Status Removal")
end
 
-- Events
local eventsText = formatEvents(rec.Events)
if eventsText then
addRow(root, "Triggers", eventsText, "sv-row-meta", "Events")
end
 
-- Notes
if type(rec.Notes) == "table" and #rec.Notes > 0 then
addRow(root, "Notes", table.concat(rec.Notes, "<br />"), "sv-row-meta", "Notes")
end
end


Line 1,764: Line 1,980:


function p.listForUser(frame)
function p.listForUser(frame)
local args = getArgs(frame)
local args = getArgs(frame)
 
 
local userName = args.user or args[1]
local userName = args.user or args[1]
if not userName or userName == "" then
if not userName or userName == "" then
userName = mw.title.getCurrentTitle().text
userName = mw.title.getCurrentTitle().text
end
end
 
 
if not userName or userName == "" then
if not userName or userName == "" then
return "<strong>No user name provided to Skill list.</strong>"
return "<strong>No user name provided to Skill list.</strong>"
end
end
 
 
local dataset = getSkills()
local dataset = getSkills()
local matches = {}
local matches = {}
 
for _, rec in ipairs(dataset.records or {}) do
if skillMatchesUser(rec, userName) then
table.insert(matches, rec)
end
end
 
if #matches == 0 then
return string.format("<strong>No skills found for:</strong> %s", mw.text.nowiki(userName))
end
 
local out = {}
 
for _, rec in ipairs(matches) do
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"


for _, rec in ipairs(dataset.records or {}) do
-- List mode: emit a raw H3 heading before each standalone card so TOC/anchors work.
if skillMatchesUser(rec, userName) then
table.insert(out, string.format("=== %s ===", title))
table.insert(matches, rec)
end
end


if #matches == 0 then
-- List mode cards are independent (no single wrapper container).
return string.format("<strong>No skills found for:</strong> %s", mw.text.nowiki(userName))
table.insert(out, buildInfobox(rec, { showUsers = false, inList = true }))
end
end


local root = mw.html.create("div")
return table.concat(out, "\n")
root:addClass("sv-skill-collection")
 
for _, rec in ipairs(matches) do
local item = root:tag("div"):addClass("sv-skill-item")
item:wikitext(buildInfobox(rec, { showUsers = false, inList = true }))
end
 
return tostring(root)
end
end