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

Join the Playtest on Steam Now: SpiritVale

Module:GameSkills: Difference between revisions

From SpiritVale Wiki
No edit summary
No edit summary
 
(7 intermediate revisions by the same user not shown)
Line 23: Line 23:


local skillsCache
local skillsCache
local eventsCache


-- getSkills: lazy-load + cache skill dataset from GameData.
-- getSkills: lazy-load + cache skill dataset from GameData.
local function getSkills()
local function getSkills()
if not skillsCache then
        if not skillsCache then
skillsCache = GameData.loadSkills()
                skillsCache = GameData.loadSkills()
end
        end
return skillsCache
        return skillsCache
end
 
local function getEvents()
        if eventsCache == nil then
                if type(GameData.loadEvents) == "function" then
                        eventsCache = GameData.loadEvents()
                else
                        eventsCache = false
                end
        end
 
        return eventsCache
end
end


Line 93: Line 106:
-- listToText: join an array into a readable string.
-- 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
return nil
                return nil
end
        end
return table.concat(list, sep or ", ")
        return table.concat(list, sep or ", ")
end
end


-- isNoneLike: treat common "none" spellings as empty.
local function resolveDisplayName(v, kind)
local function isNoneLike(v)
        if v == nil then return nil end
if v == nil then return true end
local s = mw.text.trim(tostring(v))
if s == "" then return true end
s = mw.ustring.lower(s)
return (s == "none" or s == "no" or s == "n/a" or s == "na" or s == "null")
end


-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row.
        local function firstString(keys, source)
local function addRow(tbl, label, value, rowClass, dataKey)
                for _, key in ipairs(keys) do
if value == nil or value == "" then
                        local candidate = source[key]
return
                        if type(candidate) == "string" and candidate ~= "" then
end
                                return candidate
                        end
                end
                return nil
        end


local row = tbl:tag("tr")
        if type(v) == "table" then
row:addClass("sv-row")
                local primaryKeys = { "External Name", "Display Name", "Name" }
if rowClass then row:addClass(rowClass) end
                local extendedKeys = { "Skill External Name", "Status External Name" }
if dataKey then row:attr("data-field", dataKey) end
                local internalKeys = { "Internal Name", "Internal ID", "ID", "InternalID", "Skill Internal Name", "InternalID" }
 
                return firstString(primaryKeys, v)
                        or firstString(extendedKeys, v)
                        or firstString(internalKeys, v)
        end
 
        if type(v) == "string" then
                if kind == "event" then
                        local events = getEvents()
                        if events and events.byId and events.byId[v] then
                                local mapped = resolveDisplayName(events.byId[v], "event")
                                if mapped then
                                        return mapped
                                end
                        end
                end
 
                return v
        end
 
        return tostring(v)
end


row:tag("th"):wikitext(label):done()
local function resolveEventName(v)
row:tag("td"):wikitext(value):done()
        local resolved = resolveDisplayName(v, "event")
        if type(resolved) == "string" then
                return resolved
        end
        return (resolved ~= nil) and tostring(resolved) or nil
end
end


-- formatUnitValue: format {Value, Unit} blocks (or scalar) for display.
local function resolveSkillNameFromEvent(ev)
local function formatUnitValue(v)
        if type(ev) ~= "table" then
if type(v) == "table" and v.Value ~= nil then
                return resolveDisplayName(ev, "skill") or "Unknown skill"
local unit = v.Unit
        end
local val  = v.Value
 
        local displayKeys = {
                "Skill External Name",
                "External Name",
                "Display Name",
                "Name",
                "Skill Name",
        }


if unit == "percent_decimal" or unit == "percent_whole" or unit == "percent" then
        for _, key in ipairs(displayKeys) do
return tostring(val) .. "%"
                local candidate = resolveDisplayName(ev[key], "skill")
elseif unit == "seconds" then
                if candidate then
return tostring(val) .. "s"
                        return candidate
elseif unit == "meters" then
                end
return tostring(val) .. "m"
        end
elseif unit == "tiles" then
return tostring(val) .. " tiles"
elseif unit and unit ~= "" then
return tostring(val) .. " " .. tostring(unit)
else
return tostring(val)
end
end


return (v ~= nil) and tostring(v) or nil
        local internalKeys = {
end
                "Skill Internal Name",
                "Skill ID",
                "Internal Name",
                "Internal ID",
                "ID",
        }


----------------------------------------------------------------------
        for _, key in ipairs(internalKeys) do
-- Dynamic spans (JS-driven)
                local candidate = ev[key]
----------------------------------------------------------------------
                if type(candidate) == "string" and candidate ~= "" then
                        return candidate
                end
        end


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


level = clamp(level or #series, 1, #series)
-- isNoneLike: treat common "none" spellings as empty.
local function isNoneLike(v)
if v == nil then return true end
local s = mw.text.trim(tostring(v))
if s == "" then return true end
s = mw.ustring.lower(s)
return (s == "none" or s == "no" or s == "n/a" or s == "na" or s == "null")
end


local span = mw.html.create("span")
-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row.
span:addClass("sv-dyn")
local function addRow(tbl, label, value, rowClass, dataKey)
span:attr("data-series", mw.text.jsonEncode(series))
if value == nil or value == "" then
span:wikitext(mw.text.nowiki(series[level] or ""))
return
end
 
local row = tbl:tag("tr")
row:addClass("sv-row")
if rowClass then row:addClass(rowClass) end
if dataKey then row:attr("data-field", dataKey) end


return tostring(span)
row:tag("th"):wikitext(label):done()
row:tag("td"):wikitext(value):done()
end
end


-- isFlatList: true if all values in list are identical.
-- formatUnitValue: format {Value, Unit} blocks (or scalar) for display.
local function isFlatList(list)
local function formatUnitValue(v)
if type(list) ~= "table" or #list == 0 then
if type(v) == "table" and v.Value ~= nil then
return false
local unit = v.Unit
end
local val  = v.Value
local first = tostring(list[1])
 
for i = 2, #list do
if unit == "percent_decimal" or unit == "percent_whole" or unit == "percent" then
if tostring(list[i]) ~= first then
return tostring(val) .. "%"
return false
elseif unit == "seconds" then
return tostring(val) .. "s"
elseif unit == "meters" then
return tostring(val) .. "m"
elseif unit == "tiles" then
return tostring(val) .. " tiles"
elseif unit and unit ~= "" then
return tostring(val) .. " " .. tostring(unit)
else
return tostring(val)
end
end
end
end
return true
 
return (v ~= nil) and tostring(v) or nil
end
end


-- isNonZeroScalar: detect if a value is present and not effectively zero.
----------------------------------------------------------------------
local function isNonZeroScalar(v)
-- Dynamic spans (JS-driven)
if v == nil then return false end
----------------------------------------------------------------------
if type(v) == "number" then return v ~= 0 end
if type(v) == "string" then
local n = tonumber(v)
if n == nil then return v ~= "" end
return n ~= 0
end
if type(v) == "table" and v.Value ~= nil then
return isNonZeroScalar(v.Value)
end
return true
end


-- isZeroish: aggressively treat common “zero” text forms as zero.
-- dynSpan: render a JS-updated span for a level series.
local function isZeroish(v)
local function dynSpan(series, level)
if v == nil then return true end
if type(series) ~= "table" or #series == 0 then
if type(v) == "number" then return v == 0 end
return nil
if type(v) == "table" and v.Value ~= nil then
return isZeroish(v.Value)
end
end


local s = mw.text.trim(tostring(v))
level = clamp(level or #series, 1, #series)
if s == "" 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 == "0m" or s == "0 m" then return true end
if s == "0%" or s == "0 %" then return true end


local n = tonumber((mw.ustring.gsub(s, "[^0-9%.%-]", "")))
local span = mw.html.create("span")
return (n ~= nil and n == 0)
span:addClass("sv-dyn")
span:attr("data-series", mw.text.jsonEncode(series))
span:wikitext(mw.text.nowiki(series[level] or ""))
 
return tostring(span)
end
end


-- valuePairRawText: render Base/Per Level blocks into readable text (fallback).
-- isFlatList: true if all values in list are identical.
local function valuePairRawText(block)
local function isFlatList(list)
if type(block) ~= "table" then
if type(list) ~= "table" or #list == 0 then
return nil
return false
end
local first = tostring(list[1])
for i = 2, #list do
if tostring(list[i]) ~= first then
return false
end
end
end
return true
end


local base = block.Base
-- isNonZeroScalar: detect if a value is present and not effectively zero.
local per  = block["Per Level"]
local function isNonZeroScalar(v)
 
if v == nil then return false end
if type(per) == "table" then
if type(v) == "number" then return v ~= 0 end
if #per == 0 then
if type(v) == "string" then
return formatUnitValue(base)
local n = tonumber(v)
end
if n == nil then return v ~= "" end
if isFlatList(per) then
return n ~= 0
return formatUnitValue(base) or tostring(per[1])
end
 
local vals = {}
for _, v in ipairs(per) do
table.insert(vals, formatUnitValue(v) or tostring(v))
end
return (#vals > 0) and table.concat(vals, " / ") or nil
end
end
 
if type(v) == "table" and v.Value ~= nil then
local baseText = formatUnitValue(base)
return isNonZeroScalar(v.Value)
local perText  = formatUnitValue(per)
 
if baseText and perText and isNonZeroScalar(per) then
return string.format("%s (Per Level: %s)", baseText, perText)
end
end
 
return true
return baseText or perText
end
end


-- valuePairDynamicValueOnly: render Base/Per Level blocks using dyn spans where possible.
-- isZeroish: aggressively treat common “zero” text forms as zero.
local function valuePairDynamicValueOnly(block, maxLevel, level)
local function isZeroish(v)
if v == nil then return true end
if type(v) == "number" then return v == 0 end
if type(v) == "table" and v.Value ~= nil then
return isZeroish(v.Value)
end
 
local s = mw.text.trim(tostring(v))
if s == "" then return true end
if s == "0" or s == "0.0" or s == "0.00" then return true end
if s == "0s" or s == "0 s" then return true end
if s == "0m" or s == "0 m" then return true end
if s == "0%" or s == "0 %" then return true end
 
local n = tonumber((mw.ustring.gsub(s, "[^0-9%.%-]", "")))
return (n ~= nil and n == 0)
end
 
-- valuePairRawText: render Base/Per Level blocks into readable text (fallback).
local function valuePairRawText(block)
if type(block) ~= "table" then
if type(block) ~= "table" then
return nil
return nil
Line 260: Line 325:
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 mw.text.nowiki(baseText) or nil
end
end
if isFlatList(per) then
if isFlatList(per) then
local one  = formatUnitValue(per[1]) or tostring(per[1])
return formatUnitValue(base) or tostring(per[1])
local show = formatUnitValue(base) or one
end
return show and mw.text.nowiki(show) or nil
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 dynSpan(series, level)
return (#vals > 0) and table.concat(vals, " / ") or nil
end
end


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


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


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


-- findSkillByName: locate a skill by external/display name.
-- valuePairDynamicValueOnly: render Base/Per Level blocks using dyn spans where possible.
local function findSkillByName(name)
local function valuePairDynamicValueOnly(block, maxLevel, level)
name = trim(name)
if type(block) ~= "table" then
if not name then return nil end
return nil
end


local dataset = getSkills()
local base = block.Base
local byName = dataset.byName or {}
local per  = block["Per Level"]


if byName[name] then
if type(per) == "table" then
return byName[name]
if #per == 0 then
end
local baseText = formatUnitValue(base)
 
return baseText and mw.text.nowiki(baseText) or nil
for _, rec in ipairs(dataset.records or {}) do
if type(rec) == "table" then
if rec["External Name"] == name or rec.Name == name or rec["Display Name"] == name then
return rec
end
end
end
end


return nil
if isFlatList(per) then
local one  = formatUnitValue(per[1]) or tostring(per[1])
local show = formatUnitValue(base) or one
return show and mw.text.nowiki(show) or nil
end
 
local series = {}
for _, v in ipairs(per) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
return dynSpan(series, level)
end
 
local txt = valuePairRawText(block)
return txt and mw.text.nowiki(txt) or nil
end
end


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


-- basisLabel: label ATK/MATK basis in legacy damage blocks.
-- getSkillById: locate a skill by internal ID.
local function basisLabel(entry, isHealing)
local function getSkillById(id)
if isHealing then
id = trim(id)
return "Healing"
if not id then return nil end
end
local dataset = getSkills()
return (dataset.byId or {})[id]
end


local atk  = entry and entry["ATK-Based"]
-- findSkillByName: locate a skill by external/display name.
local matk = entry and entry["MATK-Based"]
local function findSkillByName(name)
name = trim(name)
if not name then return nil end
 
local dataset = getSkills()
local byName = dataset.byName or {}
 
if byName[name] then
return byName[name]
end


if atk and matk then
for _, rec in ipairs(dataset.records or {}) do
return "Attack/Magic Attack"
if type(rec) == "table" then
elseif atk then
if rec["External Name"] == name or rec.Name == name or rec["Display Name"] == name then
return "Attack"
return rec
elseif matk then
end
return "Magic Attack"
end
end
end


return "Damage"
return nil
end
end


-- formatDamageEntry: legacy percent damage formatting (dynamic by level).
----------------------------------------------------------------------
local function formatDamageEntry(entry, maxLevel, level)
-- Legacy damage helpers
if type(entry) ~= "table" then
----------------------------------------------------------------------
 
-- basisLabel: label ATK/MATK basis in legacy damage blocks.
local function basisLabel(entry, isHealing)
if isHealing then
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
 
return "Damage"
end
 
-- formatDamageEntry: legacy percent damage formatting (dynamic by level).
local function formatDamageEntry(entry, maxLevel, level)
if type(entry) ~= "table" then
return nil
return nil
end
end
Line 478: Line 577:
local HERO_SLOT_ASSIGNMENT = {
local HERO_SLOT_ASSIGNMENT = {
[1] = "IconName",
[1] = "IconName",
[2] = "SkillType",
[2] = "Description",
[3] = "Description",
[3] = "LevelSelector",
[4] = "Placeholder",
[4] = "SkillType",
[5] = "SourceType",
[5] = "SourceType",
[6] = "QuickStats",
[6] = "QuickStats",
[7] = "SpecialMechanics",
[7] = "SpecialMechanics",
[8] = "LevelSelector",
[8] = "Placeholder",
}
}


Line 876: Line 975:
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"


local wrap = mw.html.create("div")
local notesList = {}
wrap:addClass("sv-herobar-1-wrap")
if type(rec.Notes) == "table" then
for _, note in ipairs(rec.Notes) do
local n = trim(note)
if n then
table.insert(notesList, mw.text.nowiki(n))
end
end
elseif type(rec.Notes) == "string" then
local n = trim(rec.Notes)
if n then
notesList = { mw.text.nowiki(n) }
end
end
 
local req = rec.Requirements or {}
local reqSkillsRaw = (type(req["Required Skills"]) == "table") and req["Required Skills"] or {}
local reqWeaponsRaw = (type(req["Required Weapons"]) == "table") and req["Required Weapons"] or {}
local reqStancesRaw = (type(req["Required Stances"]) == "table") and req["Required Stances"] or {}


if icon and icon ~= "" then
local reqSkills = {}
wrap:tag("div")
for _, rs in ipairs(reqSkillsRaw) do
:addClass("sv-herobar-icon")
if type(rs) == "table" then
:wikitext(string.format("[[File:%s|80px|link=]]", icon))
local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or "Unknown"
local lvlReq  = rs["Required Level"]
if lvlReq then
table.insert(reqSkills, string.format("%s (Lv.%s)", mw.text.nowiki(nameReq), mw.text.nowiki(tostring(lvlReq))))
else
table.insert(reqSkills, mw.text.nowiki(nameReq))
end
end
end
end


wrap:tag("div")
local reqWeapons = {}
:addClass("spiritvale-infobox-title")
for _, w in ipairs(reqWeaponsRaw) do
:wikitext(title)
local wn = trim(w)
if wn then table.insert(reqWeapons, mw.text.nowiki(wn)) end
end


return {
local reqStances = {}
inner = tostring(wrap),
for _, s in ipairs(reqStancesRaw) do
classes = "module-icon-name",
local sn = trim(s)
}
if sn then table.insert(reqStances, mw.text.nowiki(sn)) end
end
end


-- PLUGIN: SkillType (Hero Bar Slot 2) - 2 rows x 3 cells (desktop + mobile).
local hasNotes = (#notesList > 0)
-- Rules:
local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0)
--  - If skill is non-damaging, hide Damage/Element/Hits.
--  - If Hits is empty, hide Hits.
--  - If Combo is empty, hide Combo.
-- Ordering:
--  - 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 {}


local level    = ctx.level or 1
local maxLevel = ctx.maxLevel or 1


local hideDamageBundle = (ctx.nonDamaging == true)
local wrap = mw.html.create("div")
wrap:addClass("sv-herobar-1-wrap")
wrap:addClass("sv-tip-scope")


-- valName: extract a display string from typical {Name/ID/Value} objects.
local iconBox = wrap:tag("div")
-- NOTE: Includes number support so Hits=2 (number) doesn't get dropped.
iconBox:addClass("sv-herobar-icon")
local function valName(x)
 
if x == nil then return nil end
if icon and icon ~= "" then
if type(x) == "table" then
iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon))
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
end


-- hitsDisplay: find + render Hits from multiple possible structured locations.
local textBox = wrap:tag("div")
local function hitsDisplay()
textBox:addClass("sv-herobar-text")
local effects = (type(mech.Effects) == "table") and mech.Effects or {}


local h =
local titleRow = textBox:tag("div")
typeBlock.Hits or typeBlock["Hits"] or typeBlock["Hit Count"] or typeBlock["Hits Count"] or
titleRow:addClass("sv-herobar-title-row")
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
local titleBox = titleRow:tag("div")
return nil
titleBox:addClass("spiritvale-infobox-title")
end
titleBox:wikitext(title)


-- ValuePair-style table (Base/Per Level) => dynamic series
if hasNotes then
if type(h) == "table" then
local notesBtn = mw.html.create("span")
if h.Base ~= nil or h["Per Level"] ~= nil or type(h["Per Level"]) == "table" then
notesBtn:addClass("sv-tip-btn sv-tip-btn--notes")
return displayFromSeries(seriesFromValuePair(h, maxLevel), level)
notesBtn:attr("role", "button")
end
notesBtn:attr("tabindex", "0")
notesBtn:attr("data-sv-tip", "notes")
notesBtn:attr("aria-label", "Notes")
notesBtn:attr("aria-expanded", "false")
notesBtn:tag("span"):addClass("sv-ico sv-ico--info"):attr("aria-hidden", "true"):wikitext("i")
titleRow:node(notesBtn)
end
 
if hasReq then
local pillRow = wrap:tag("div")
pillRow:addClass("sv-pill-row")
pillRow:addClass("sv-pill-row--req")
local pill = pillRow:tag("span")
pill:addClass("sv-pill sv-pill--req sv-tip-btn")
pill:attr("role", "button")
pill:attr("tabindex", "0")
pill:attr("data-sv-tip", "req")
pill:attr("aria-label", "Requirements")
pill:attr("aria-expanded", "false")
pill:wikitext("Requirements")
end


-- Unit block {Value, Unit}
if hasNotes then
if h.Value ~= nil then
local notesContent = wrap:tag("div")
local t = formatUnitValue(h)
notesContent:addClass("sv-tip-content")
return t and mw.text.nowiki(t) or nil
notesContent:attr("data-sv-tip-content", "notes")
end
notesContent:tag("div"):addClass("sv-tip-title"):wikitext("Notes")
notesContent:tag("div"):wikitext(table.concat(notesList, "<br />"))
end


-- Fallback name extraction
if hasReq then
local function valName(x)
local reqContent = wrap:tag("div")
if x == nil then return nil end
reqContent:addClass("sv-tip-content")
if type(x) == "table" then
reqContent:attr("data-sv-tip-content", "req")
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 #reqSkills > 0 then
if vn and not isNoneLike(vn) then
local section = reqContent:tag("div")
return mw.text.nowiki(vn)
section:addClass("sv-tip-section")
end
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Skills")
section:tag("div"):wikitext(table.concat(reqSkills, "<br />"))
end
end


-- Scalar number/string
if #reqWeapons > 0 then
if type(h) == "number" then
local section = reqContent:tag("div")
return mw.text.nowiki(fmtNum(h))
section:addClass("sv-tip-section")
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Weapons")
section:tag("div"):wikitext(table.concat(reqWeapons, ", "))
end
end
if type(h) == "string" then
 
local t = trim(h)
if #reqStances > 0 then
return (t and not isNoneLike(t)) and mw.text.nowiki(t) or nil
local section = reqContent:tag("div")
section:addClass("sv-tip-section")
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Stances")
section:tag("div"):wikitext(table.concat(reqStances, ", "))
end
end
return nil
end
end


-- comboDisplay: render Combo as a compact text block (Type (+ details)).
return {
local function comboDisplay()
inner = tostring(wrap),
local c = (type(mech.Combo) == "table") and mech.Combo or nil
classes = "module-icon-name",
if not c then return nil end
}
end


local typ = trim(c.Type)
-- PLUGIN: SkillType (Hero Bar Slot 2) - 2 rows x 3 cells (desktop + mobile).
if not typ or isNoneLike(typ) then
-- Rules:
return nil
--  - If skill is non-damaging, hide Damage/Element/Hits.
end
--  - If Hits is empty, hide Hits.
--  - If Combo is empty, hide Combo.
-- Ordering:
--  - 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 {}


local details = {}
local level    = ctx.level or 1
local maxLevel = ctx.maxLevel or 1


local pct = formatUnitValue(c.Percent)
local hideDamageBundle = (ctx.nonDamaging == true)
if pct and not isZeroish(pct) then
table.insert(details, mw.text.nowiki(pct))
end


local dur = formatUnitValue(c.Duration)
-- valName: extract a display string from typical {Name/ID/Value} objects.
if dur and not isZeroish(dur) then
-- NOTE: Includes number support so Hits=2 (number) doesn't get dropped.
table.insert(details, mw.text.nowiki(dur))
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
end
 
if type(x) == "number" then
if #details > 0 then
return tostring(x)
return mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")"
end
if type(x) == "string" and x ~= "" then
return x
end
end
return mw.text.nowiki(typ)
return nil
end
end


local grid = mw.html.create("div")
-- hitsDisplay: find + render Hits from multiple possible structured locations.
grid:addClass("sv-type-grid")
local function hitsDisplay()
grid:addClass("sv-compact-root")
local effects = (type(mech.Effects) == "table") and mech.Effects or {}


local added = false
local h =
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"]


-- addChunk: add one labeled value cell (key drives CSS ordering).
if h == nil or isNoneLike(h) then
local function addChunk(key, label, valueHtml)
return nil
if valueHtml == nil or valueHtml == "" then return end
end
added = true


local chunk = grid:tag("div")
-- ValuePair-style table (Base/Per Level) => dynamic series
:addClass("sv-type-chunk")
if type(h) == "table" then
:addClass("sv-type-" .. tostring(key))
if h.Base ~= nil or h["Per Level"] ~= nil or type(h["Per Level"]) == "table" then
:attr("data-type-key", tostring(key))
return displayFromSeries(seriesFromValuePair(h, maxLevel), level)
end


chunk:tag("div")
-- Unit block {Value, Unit}
:addClass("sv-type-label")
if h.Value ~= nil then
:wikitext(mw.text.nowiki(label))
local t = formatUnitValue(h)
return t and mw.text.nowiki(t) or nil
end


chunk:tag("div")
-- Fallback name extraction
:addClass("sv-type-value")
local function valName(x)
:wikitext(valueHtml)
if x == nil then return nil end
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


-- Damage + Element + Hits bundle (hidden when non-damaging)
local vn = valName(h)
if not hideDamageBundle then
if vn and not isNoneLike(vn) then
local dmg  = valName(typeBlock.Damage or typeBlock["Damage Type"])
return mw.text.nowiki(vn)
local ele  = valName(typeBlock.Element or typeBlock["Element Type"])
end
local hits = hitsDisplay()
end


if dmg and not isNoneLike(dmg) then
-- Scalar number/string
addChunk("damage", "Damage", mw.text.nowiki(dmg))
if type(h) == "number" then
return mw.text.nowiki(fmtNum(h))
end
end
if ele and not isNoneLike(ele) then
if type(h) == "string" then
addChunk("element", "Element", mw.text.nowiki(ele))
local t = trim(h)
end
return (t and not isNoneLike(t)) and mw.text.nowiki(t) or nil
if hits then
addChunk("hits", "Hits", hits)
end
end
return nil
end
end


-- Target + Cast
-- comboDisplay: render Combo as a compact text block (Type (+ details)).
local tgt = valName(typeBlock.Target or typeBlock["Target Type"])
local function comboDisplay()
local cst = valName(typeBlock.Cast  or typeBlock["Cast Type"])
local c = (type(mech.Combo) == "table") and mech.Combo or nil
if not c then return nil end


if tgt and not isNoneLike(tgt) then
local typ = trim(c.Type)
addChunk("target", "Target", mw.text.nowiki(tgt))
if not typ or isNoneLike(typ) then
end
return nil
if cst and not isNoneLike(cst) then
end
addChunk("cast", "Cast", mw.text.nowiki(cst))
end


-- Combo
local details = {}
local combo = comboDisplay()
if combo then
addChunk("combo", "Combo", combo)
end


        return {
local pct = formatUnitValue(c.Percent)
                inner = added and tostring(grid) or "",
if pct and not isZeroish(pct) then
                classes = "module-skill-type",
table.insert(details, mw.text.nowiki(pct))
        }
end
end


-- PLUGIN: Description (Hero Slot 3) - primary description text.
local dur = formatUnitValue(c.Duration)
function PLUGINS.Description(rec)
if dur and not isZeroish(dur) then
        local desc = trim(rec.Description)
table.insert(details, mw.text.nowiki(dur))
        if not desc then
end
                return nil
 
        end
if #details > 0 then
return mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")"
end
return mw.text.nowiki(typ)
end


        local body = mw.html.create("div")
local grid = mw.html.create("div")
        body:addClass("sv-description")
grid:addClass("sv-type-grid")
        body:wikitext(string.format("''%s''", desc))
grid:addClass("sv-compact-root")


        return {
local added = false
                inner = tostring(body),
                classes = "module-description",
        }
end


-- PLUGIN: Placeholder (Hero Slot 4) - reserved/blank.
-- addChunk: add one labeled value cell (key drives CSS ordering).
function PLUGINS.Placeholder()
local function addChunk(key, label, valueHtml)
        return nil
if valueHtml == nil or valueHtml == "" then return end
end
added = true


function PLUGINS.Description(rec, ctx)
local chunk = grid:tag("div")
        local desc = trim(rec.Description)
:addClass("sv-type-chunk")
        if not desc then
:addClass("sv-type-" .. tostring(key))
                return nil
:attr("data-type-key", tostring(key))
        end


        local wrapper = mw.html.create("div")
chunk:tag("div")
        wrapper:addClass("sv-module-description")
:addClass("sv-type-label")
        wrapper:wikitext(string.format("''%s''", desc))
:wikitext(mw.text.nowiki(label))


        return {
chunk:tag("div")
                inner = tostring(wrapper),
:addClass("sv-type-value")
                classes = "module-description",
:wikitext(valueHtml)
        }
end
end


-- PLUGIN: SourceType (Hero Module Slot 1) - Modifier + Source + Scaling.
-- Damage + Element + Hits bundle (hidden when non-damaging)
function PLUGINS.SourceType(rec, ctx)
if not hideDamageBundle then
local level = ctx.level or 1
local dmg  = valName(typeBlock.Damage or typeBlock["Damage Type"])
local maxLevel = ctx.maxLevel or 1
local ele  = valName(typeBlock.Element or typeBlock["Element Type"])
local hits = hitsDisplay()


local basisWord = nil
if dmg and not isNoneLike(dmg) then
local sourceKind = nil
addChunk("damage", "Damage", mw.text.nowiki(dmg))
local sourceVal  = nil
end
local scaling    = nil
if ele and not isNoneLike(ele) then
 
addChunk("element", "Element", mw.text.nowiki(ele))
-- sourceValueForLevel: dynamic formatting for structured Source blocks.
end
local function sourceValueForLevel(src)
if hits then
if type(src) ~= "table" then
addChunk("hits", "Hits", hits)
return nil
end
end
end


local per = src["Per Level"]
-- Target + Cast
if type(per) == "table" and #per > 0 then
local tgt = valName(typeBlock.Target or typeBlock["Target Type"])
if isFlatList(per) then
local cst = valName(typeBlock.Cast  or typeBlock["Cast Type"])
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 = {}
if tgt and not isNoneLike(tgt) then
for _, v in ipairs(per) do
addChunk("target", "Target", mw.text.nowiki(tgt))
table.insert(series, formatUnitValue(v) or tostring(v))
end
end
if cst and not isNoneLike(cst) then
return dynSpan(series, level)
addChunk("cast", "Cast", mw.text.nowiki(cst))
end
end


return valuePairDynamicValueOnly(src, maxLevel, level)
-- Combo
local combo = comboDisplay()
if combo then
addChunk("combo", "Combo", combo)
end
end


if type(rec.Source) == "table" then
        return {
local src = rec.Source
                inner = added and tostring(grid) or "",
local atkFlag  = (src["ATK-Based"] == true)
                classes = "module-skill-type",
local matkFlag = (src["MATK-Based"] == true)
        }
basisWord = basisWordFromFlags(atkFlag, matkFlag)
end
 
sourceKind = src.Type or ((src.Healing == true) and "Healing") or "Damage"
sourceVal  = sourceValueForLevel(src)
scaling    = src.Scaling
end


-- Fallback to legacy Damage lists if Source absent
-- PLUGIN: Description (Hero Slot 3) - primary description text.
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
function PLUGINS.Description(rec)
local dmg = rec.Damage
        local desc = trim(rec.Description)
scaling = scaling or dmg.Scaling
        if not desc then
                return nil
        end


local main = dmg["Main Damage"]
        local body = mw.html.create("div")
local refl = dmg["Reflect Damage"]
        body:addClass("sv-description")
local flat = dmg["Flat Damage"]
        body:wikitext(string.format("''%s''", desc))


if type(main) == "table" and #main > 0 then
        return {
local pick = nil
                inner = tostring(body),
for _, d in ipairs(main) do
                classes = "module-description",
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
-- PLUGIN: Placeholder (Hero Slot 4) - reserved/blank.
local atkFlag  = (pick["ATK-Based"] == true)
function PLUGINS.Placeholder()
local matkFlag = (pick["MATK-Based"] == true)
        return nil
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)
end


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)


sourceKind = "Reflect"
-- PLUGIN: SourceType (Hero Module Slot 1) - Modifier + Source + Scaling.
sourceVal  = legacyPercentAtLevel(pick, level)
function PLUGINS.SourceType(rec, ctx)
elseif type(flat) == "table" and #flat > 0 and type(flat[1]) == "table" then
local level = ctx.level or 1
local pick = flat[1]
local maxLevel = ctx.maxLevel or 1
local atkFlag  = (pick["ATK-Based"] == true)
 
local matkFlag = (pick["MATK-Based"] == true)
local basisWord = nil
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)
local sourceKind = nil
local sourceVal  = nil
local scaling    = nil


sourceKind = "Flat"
-- sourceValueForLevel: dynamic formatting for structured Source blocks.
sourceVal  = legacyPercentAtLevel(pick, level)
local function sourceValueForLevel(src)
if type(src) ~= "table" then
return nil
end
end
end


local scalingLines = formatScalingCompactLines(scaling)
local per = src["Per Level"]
local hasSource    = (sourceVal ~= nil and tostring(sourceVal) ~= "")
if type(per) == "table" and #per > 0 then
local hasScaling  = (type(scalingLines) == "table" and #scalingLines > 0)
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


if (not hasSource) and (not hasScaling) then
local series = {}
return nil
for _, v in ipairs(per) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
return dynSpan(series, level)
end
 
return valuePairDynamicValueOnly(src, maxLevel, level)
end
end


local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")
if type(rec.Source) == "table" then
local src = rec.Source
local atkFlag  = (src["ATK-Based"] == true)
local matkFlag = (src["MATK-Based"] == true)
basisWord = basisWordFromFlags(atkFlag, matkFlag)


local extra = { "skill-source-module", "module-source-type" }
sourceKind = src.Type or ((src.Healing == true) and "Healing") or "Damage"
table.insert(extra, hasMod and "sv-has-mod" or "sv-no-mod")
sourceVal  = sourceValueForLevel(src)
 
scaling    = src.Scaling
if hasSource and (not hasScaling) then
table.insert(extra, "sv-only-source")
elseif hasScaling and (not hasSource) then
table.insert(extra, "sv-only-scaling")
end
end


local wrap = mw.html.create("div")
-- Fallback to legacy Damage lists if Source absent
wrap:addClass("sv-source-grid")
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
wrap:addClass("sv-compact-root")
local dmg = rec.Damage
scaling = scaling or dmg.Scaling


if hasMod then
local main = dmg["Main Damage"]
local modCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-modifier")
local refl = dmg["Reflect Damage"]
modCol:tag("div"):addClass("sv-source-pill"):wikitext("Modifier")
local flat = dmg["Flat Damage"]
modCol:tag("div"):addClass("sv-modifier-value"):wikitext(mw.text.nowiki(basisWord))
end


if hasSource then
if type(main) == "table" and #main > 0 then
local sourceCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-main")
local pick = nil
sourceCol:tag("div"):addClass("sv-source-pill"):wikitext(mw.text.nowiki(sourceKind or "Source"))
for _, d in ipairs(main) do
sourceCol:tag("div"):addClass("sv-source-value"):wikitext(sourceVal)
if type(d) == "table" and d.Type ~= "Healing" then
end
pick = d
break
end
end
pick = pick or main[1]


if hasScaling then
if type(pick) == "table" then
local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling")
local atkFlag  = (pick["ATK-Based"] == true)
scalingCol:tag("div"):addClass("sv-source-pill"):wikitext("Scaling")
local matkFlag = (pick["MATK-Based"] == true)
basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)
 
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)
 
sourceKind = "Reflect"
sourceVal  = legacyPercentAtLevel(pick, level)
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)


local list = scalingCol:tag("div"):addClass("sv-scaling-list")
sourceKind = "Flat"
for _, line in ipairs(scalingLines) do
sourceVal  = legacyPercentAtLevel(pick, level)
list:tag("div"):addClass("sv-scaling-item"):wikitext(mw.text.nowiki(line))
end
end
end
end


return {
local scalingLines = formatScalingCompactLines(scaling)
inner = tostring(wrap),
local hasSource    = (sourceVal ~= nil and tostring(sourceVal) ~= "")
classes = extra,
local hasScaling  = (type(scalingLines) == "table" and #scalingLines > 0)
}
end


-- PLUGIN: QuickStats (Hero Module Slot 2) - 3x2 grid (range/area/cost/cast/cd/duration).
if (not hasSource) and (not hasScaling) then
-- NOTE: Hits does NOT live here (it lives in SkillType).
return nil
function PLUGINS.QuickStats(rec, ctx)
end
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 {}
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
local rc  = (type(mech["Resource Cost"]) == "table") and mech["Resource Cost"] or {}


local function dash() return "" end
local extra = { "skill-source-module", "module-source-type" }
table.insert(extra, hasMod and "sv-has-mod" or "sv-no-mod")


-- Range (0 => —)
if hasSource and (not hasScaling) then
local rangeVal = nil
table.insert(extra, "sv-only-source")
if mech.Range ~= nil and not isNoneLike(mech.Range) then
elseif hasScaling and (not hasSource) then
local n = toNum(mech.Range)
table.insert(extra, "sv-only-scaling")
if n ~= nil then
end
if n ~= 0 then
rangeVal = mw.text.nowiki(formatUnitValue(mech.Range) or tostring(mech.Range))
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


-- Area
local wrap = mw.html.create("div")
local areaVal = formatAreaSize(mech.Area, maxLevel, level)
wrap:addClass("sv-source-grid")
wrap:addClass("sv-compact-root")


-- Timings
if hasMod then
local castVal = displayFromSeries(seriesFromValuePair(bt["Cast Time"], maxLevel), level)
local modCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-modifier")
local cdVal  = displayFromSeries(seriesFromValuePair(bt["Cooldown"],  maxLevel), level)
modCol:tag("div"):addClass("sv-source-pill"):wikitext("Modifier")
local durVal  = displayFromSeries(seriesFromValuePair(bt["Duration"],  maxLevel), level)
modCol:tag("div"):addClass("sv-modifier-value"):wikitext(mw.text.nowiki(basisWord))
 
-- Promote status duration if needed
if (durVal == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then
durVal = displayFromSeries(seriesFromValuePair(promo.durationBlock, maxLevel), level)
end
end


-- Cost: MP + HP
if hasSource then
local function labeledSeries(block, label)
local sourceCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-main")
local s = seriesFromValuePair(block, maxLevel)
sourceCol:tag("div"):addClass("sv-source-pill"):wikitext(mw.text.nowiki(sourceKind or "Source"))
if not s then return nil end
sourceCol:tag("div"):addClass("sv-source-value"):wikitext(sourceVal)
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
end


local mpS = labeledSeries(rc["Mana Cost"], "MP")
if hasScaling then
local hpS = labeledSeries(rc["Health Cost"], "HP")
local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling")
scalingCol:tag("div"):addClass("sv-source-pill"):wikitext("Scaling")


local costSeries = {}
local list = scalingCol:tag("div"):addClass("sv-scaling-list")
for lv = 1, maxLevel do
for _, line in ipairs(scalingLines) do
local mp = mpS and mpS[lv] or ""
list:tag("div"):addClass("sv-scaling-item"):wikitext(mw.text.nowiki(line))
local hp = hpS and hpS[lv] or "—"
end
end


if mp ~= "—" and hp ~= "—" then
return {
costSeries[lv] = mp .. " + " .. hp
inner = tostring(wrap),
elseif mp ~= "—" then
classes = extra,
costSeries[lv] = mp
}
elseif hp ~= "—" then
end
costSeries[lv] = hp
else
costSeries[lv] = "—"
end
end


local costVal = displayFromSeries(costSeries, level)
-- PLUGIN: QuickStats (Hero Module Slot 2) - 3x2 grid (range/area/cost/cast/cd/duration).
-- 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 grid = mw.html.create("div")
local mech = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
grid:addClass("sv-m4-grid")
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
grid:addClass("sv-compact-root")
local rc  = (type(mech["Resource Cost"]) == "table") and mech["Resource Cost"] or {}
 
local function dash() return "" end


local function addCell(label, val)
-- Range (0 => —)
local cell = grid:tag("div"):addClass("sv-m4-cell")
local rangeVal = nil
cell:tag("div"):addClass("sv-m4-label"):wikitext(mw.text.nowiki(label))
if mech.Range ~= nil and not isNoneLike(mech.Range) then
cell:tag("div"):addClass("sv-m4-value"):wikitext(val or dash())
local n = toNum(mech.Range)
if n ~= nil then
if n ~= 0 then
rangeVal = mw.text.nowiki(formatUnitValue(mech.Range) or tostring(mech.Range))
end
else
local t = mw.text.trim(tostring(mech.Range))
if t ~= "" and not isNoneLike(t) then
rangeVal = mw.text.nowiki(t)
end
end
end
end


addCell("Range",    rangeVal)
-- Area
addCell("Area",      areaVal)
local areaVal = formatAreaSize(mech.Area, maxLevel, level)
addCell("Cost",      costVal)
addCell("Cast Time", castVal)
addCell("Cooldown", cdVal)
addCell("Duration", durVal)


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


-- PLUGIN: SpecialMechanics (Hero Module Slot 3)
-- Promote status duration if needed
-- Shows:
if (durVal == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then
--  - Flags (deduped)
durVal = displayFromSeries(seriesFromValuePair(promo.durationBlock, maxLevel), level)
--  - Special mechanics (mech.Effects)
end
-- 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 {}
-- Cost: MP + HP
local effects = (type(mech.Effects) == "table") and mech.Effects or nil
local function labeledSeries(block, label)
local mods    = (type(rec.Modifiers) == "table") and rec.Modifiers or nil
local s = seriesFromValuePair(block, maxLevel)
 
if not s then return nil end
------------------------------------------------------------------
local any = false
-- Hits guard (we want Hits ONLY in SkillType)
for i, v in ipairs(s) do
------------------------------------------------------------------
if v ~= "" then
local function isHitsKey(name)
s[i] = tostring(v) .. " " .. label
if not name then return false end
any = true
local k = mw.ustring.lower(mw.text.trim(tostring(name)))
else
return (
s[i] = ""
k == "hit" or
end
k == "hits" or
end
k == "hit count" or
return any and s or nil
k == "hits count" or
k == "hitcount" or
k == "hitscount"
)
end
end


------------------------------------------------------------------
local mpS = labeledSeries(rc["Mana Cost"], "MP")
-- Flags (flat, de-duped)
local hpS = labeledSeries(rc["Health Cost"], "HP")
------------------------------------------------------------------
 
local flagSet = {}
local costSeries = {}
for lv = 1, maxLevel do
local mp = mpS and mpS[lv] or "—"
local hp = hpS and hpS[lv] or "—"
 
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


local denyFlags = {
local costVal = displayFromSeries(costSeries, level)
["self centered"] = true,
["self-centred"] = true,
["bond"] = true,
["combo"] = true,
        ["hybrid"] = true,


-- hits variants
local grid = mw.html.create("div")
["hit"] = true,
grid:addClass("sv-m4-grid")
["hits"] = true,
grid:addClass("sv-compact-root")
["hit count"] = true,
["hits count"] = true,
["hitcount"] = true,
["hitscount"] = true,
}


local function allowFlag(name)
local function addCell(label, val)
if not name then return false end
local cell = grid:tag("div"):addClass("sv-m4-cell")
local k = mw.ustring.lower(mw.text.trim(tostring(name)))
cell:tag("div"):addClass("sv-m4-label"):wikitext(mw.text.nowiki(label))
if k == "" then return false end
cell:tag("div"):addClass("sv-m4-value"):wikitext(val or dash())
if denyFlags[k] then return false end
return true
end
end


local function addFlags(sub)
addCell("Range",    rangeVal)
if type(sub) ~= "table" then return end
addCell("Area",      areaVal)
for k, v in pairs(sub) do
addCell("Cost",      costVal)
if v and allowFlag(k) then
addCell("Cast Time", castVal)
flagSet[tostring(k)] = true
addCell("Cooldown",  cdVal)
end
addCell("Duration",  durVal)
end
 
end
return {
inner = tostring(grid),
classes = "module-quick-stats",
}
end


if mods then
-- PLUGIN: SpecialMechanics (Hero Module Slot 3)
addFlags(mods["Movement Modifiers"])
-- Shows:
addFlags(mods["Combat Modifiers"])
--  - Flags (deduped)
addFlags(mods["Special Modifiers"])
--  - Special mechanics (mech.Effects)
for k, v in pairs(mods) do
-- NOTE: Combo lives in SkillType (Hero Bar Slot 2).
if type(v) == "boolean" and v and allowFlag(k) then
function PLUGINS.SpecialMechanics(rec, ctx)
flagSet[tostring(k)] = true
local level = ctx.level or 1
end
local maxLevel = ctx.maxLevel or 1
end
end


local flags = {}
local mech    = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
for k, _ in pairs(flagSet) do table.insert(flags, k) end
local effects = (type(mech.Effects) == "table") and mech.Effects or nil
table.sort(flags)
local mods    = (type(rec.Modifiers) == "table") and rec.Modifiers or nil


------------------------------------------------------------------
------------------------------------------------------------------
-- Special mechanics (name => value)
-- Hits guard (we want Hits ONLY in SkillType)
------------------------------------------------------------------
------------------------------------------------------------------
local mechItems = {}
local function isHitsKey(name)
if not name then return false end
local k = mw.ustring.lower(mw.text.trim(tostring(name)))
return (
k == "hit" or
k == "hits" or
k == "hit count" or
k == "hits count" or
k == "hitcount" or
k == "hitscount"
)
end


if effects then
------------------------------------------------------------------
local keys = {}
-- Flags (flat, de-duped)
for k, _ in pairs(effects) do table.insert(keys, k) end
------------------------------------------------------------------
table.sort(keys)
local flagSet = {}


for _, name in ipairs(keys) do
local denyFlags = {
-- Skip Hits completely (it belongs in SkillType)
["self centered"] = true,
if not isHitsKey(name) then
["self-centred"] = true,
local block = effects[name]
["bond"] = true,
if type(block) == "table" then
["combo"] = true,
-- Also skip if the block's Type is "Hits" (some data may encode it that way)
        ["hybrid"] = true,
if not isHitsKey(block.Type) then
local disp = displayFromSeries(seriesFromValuePair(block, maxLevel), level)
local t = trim(block.Type)


local value = disp
-- hits variants
["hit"] = true,
["hits"] = true,
["hit count"] = true,
["hits count"] = true,
["hitcount"] = true,
["hitscount"] = true,
}


-- If Type exists and is distinct, prefix it.
local function allowFlag(name)
if t and not isNoneLike(t) and mw.ustring.lower(t) ~= mw.ustring.lower(tostring(name)) then
if not name then return false end
if value then
local k = mw.ustring.lower(mw.text.trim(tostring(name)))
value = mw.text.nowiki(t) .. ": " .. value
if k == "" then return false end
else
if denyFlags[k] then return false end
value = mw.text.nowiki(t)
return true
end
end
end


if value then
local function addFlags(sub)
table.insert(mechItems, { label = tostring(name), value = value })
if type(sub) ~= "table" then return end
end
for k, v in pairs(sub) do
end
if v and allowFlag(k) then
end
flagSet[tostring(k)] = true
end
end
end
end
end
end


local hasFlags = (#flags > 0)
if mods then
local hasMech  = (#mechItems > 0)
addFlags(mods["Movement Modifiers"])
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


if (not hasFlags) and (not hasMech) then
local flags = {}
local root = mw.html.create("div")
for k, _ in pairs(flagSet) do table.insert(flags, k) end
root:addClass("sv-sm-root")
table.sort(flags)
root:addClass("sv-compact-root")
root:tag("div"):addClass("sv-sm-empty"):wikitext("No Special Mechanics")


return {
------------------------------------------------------------------
inner = tostring(root),
-- Special mechanics (name => value)
classes = "module-special-mechanics",
------------------------------------------------------------------
}
local mechItems = {}
end


local count = 0
if effects then
if hasFlags then count = count + 1 end
local keys = {}
if hasMech  then count = count + 1 end
for k, _ in pairs(effects) do table.insert(keys, k) end
table.sort(keys)


local root = mw.html.create("div")
for _, name in ipairs(keys) do
root:addClass("sv-sm-root")
-- Skip Hits completely (it belongs in SkillType)
root:addClass("sv-compact-root")
if not isHitsKey(name) then
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 value = disp


local layout = root:tag("div"):addClass("sv-sm-layout")
-- If Type exists and is distinct, prefix it.
layout:addClass("sv-sm-count-" .. tostring(count))
if t and not isNoneLike(t) and mw.ustring.lower(t) ~= mw.ustring.lower(tostring(name)) then
if value then
value = mw.text.nowiki(t) .. ": " .. value
else
value = mw.text.nowiki(t)
end
end


-- Column 1: Flags
if value then
if hasFlags then
table.insert(mechItems, { label = tostring(name), value = value })
local fcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-flags")
end
for _, f in ipairs(flags) do
end
fcol:tag("div"):addClass("sv-sm-flag"):wikitext(mw.text.nowiki(f))
end
end
end
end
end
end


-- Column 2: Special Mechanics (stacked)
local hasFlags = (#flags > 0)
if hasMech then
local hasMech  = (#mechItems > 0)
local mcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-mech")
 
for _, it in ipairs(mechItems) do
if (not hasFlags) and (not hasMech) then
local one = mcol:tag("div"):addClass("sv-sm-mech")
local root = mw.html.create("div")
one:tag("div"):addClass("sv-sm-label"):wikitext(mw.text.nowiki(it.label))
root:addClass("sv-sm-root")
one:tag("div"):addClass("sv-sm-value"):wikitext(it.value or "—")
root:addClass("sv-compact-root")
end
root:tag("div"):addClass("sv-sm-empty"):wikitext("No Special Mechanics")
 
return {
inner = tostring(root),
classes = "module-special-mechanics",
}
end
end


return {
local count = 0
inner = tostring(root),
if hasFlags then count = count + 1 end
classes = "module-special-mechanics",
if hasMech  then count = count + 1 end
}
end


-- PLUGIN: LevelSelector (Hero Module Slot 4) - JS level slider.
local root = mw.html.create("div")
function PLUGINS.LevelSelector(rec, ctx)
root:addClass("sv-sm-root")
local level = ctx.level or 1
root:addClass("sv-compact-root")
local maxLevel = ctx.maxLevel or 1


local inner = mw.html.create("div")
local layout = root:tag("div"):addClass("sv-sm-layout")
inner:addClass("sv-level-ui")
layout:addClass("sv-sm-count-" .. tostring(count))


inner:tag("div")
-- Column 1: Flags
:addClass("sv-level-label")
if hasFlags then
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
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


local slider = inner:tag("div"):addClass("sv-level-slider")
-- Column 2: Special Mechanics (stacked)
 
if hasMech then
if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
local mcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-mech")
slider:tag("input")
for _, it in ipairs(mechItems) do
local one = mcol:tag("div"):addClass("sv-sm-mech")
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
 
return {
inner = tostring(root),
classes = "module-special-mechanics",
}
end
 
-- PLUGIN: LevelSelector (Hero Module Slot 4) - JS level slider.
function PLUGINS.LevelSelector(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
 
local inner = mw.html.create("div")
inner:addClass("sv-level-ui")
 
inner:tag("div")
:addClass("sv-level-label")
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
 
local slider = inner:tag("div"):addClass("sv-level-slider")
 
if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
slider:tag("input")
:attr("type", "range")
:attr("type", "range")
:attr("min", "1")
:attr("min", "1")
Line 1,749: Line 1,951:


-- Users (hide on direct skill page)
-- Users (hide on direct skill page)
if showUsers then
        if showUsers then
local users = rec.Users or {}
                local users = rec.Users or {}
addRow(root, "Classes",  listToText(users.Classes),  "sv-row-users", "Users.Classes")
                addRow(root, "Classes",  listToText(users.Classes),  "sv-row-users", "Users.Classes")
addRow(root, "Summons",  listToText(users.Summons),  "sv-row-users", "Users.Summons")
                addRow(root, "Summons",  listToText(users.Summons),  "sv-row-users", "Users.Summons")
addRow(root, "Monsters", listToText(users.Monsters), "sv-row-users", "Users.Monsters")
                addRow(root, "Monsters", listToText(users.Monsters), "sv-row-users", "Users.Monsters")
addRow(root, "Events",   listToText(users.Events),   "sv-row-users", "Users.Events")
                do
end
                        local eventsList = {}
                        if type(users.Events) == "table" then
                                for _, ev in ipairs(users.Events) do
                                        local name = resolveEventName(ev) or ev
                                        if name ~= nil then
                                                table.insert(eventsList, mw.text.nowiki(tostring(name)))
                                        end
                                end
                        end
                        addRow(root, "Events", listToText(eventsList), "sv-row-users", "Users.Events")
                end
        end


-- Requirements
        -- Mechanics (keep small extras only)
local req = rec.Requirements or {}
        local mech = rec.Mechanics or {}
local hasReq =
        if next(mech) ~= nil then
(type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0) or
                if mech["Autocast Multiplier"] ~= nil then
(type(req["Required Weapons"]) == "table" and #req["Required Weapons"] > 0) or
                        addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
(type(req["Required Stances"]) == "table" and #req["Required Stances"] > 0)
                end
 
        end
if hasReq then
if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
local skillParts = {}
for _, rs in ipairs(req["Required Skills"]) do
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
table.insert(skillParts, nameReq)
end
end
addRow(root, "Required Skills", table.concat(skillParts, ", "), "sv-row-req", "Requirements.Required Skills")
end
 
addRow(root, "Required Weapons", listToText(req["Required Weapons"]), "sv-row-req", "Requirements.Required Weapons")
addRow(root, "Required Stances", listToText(req["Required Stances"]), "sv-row-req", "Requirements.Required Stances")
end
 
-- Mechanics (keep small extras only)
local mech = rec.Mechanics or {}
if next(mech) ~= nil then
if mech["Autocast Multiplier"] ~= nil then
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
end
end


-- Legacy damage breakdown (only when Source absent)
-- Legacy damage breakdown (only when Source absent)
Line 1,866: Line 2,053:


local amt = valuePairRawText(r)
local amt = valuePairRawText(r)
amt = amt and mw.text.nowiki(amt) or nil
amt = amt and mw.text.nowiki(amt) or nil
 
 
local seg = mw.text.nowiki(label)
local seg = mw.text.nowiki(label)
if amt then
if amt then
seg = seg .. " – " .. amt
seg = seg .. " – " .. amt
end
end
table.insert(parts, seg)
table.insert(parts, seg)
end
end
end
end
 
 
return (#parts > 0) and table.concat(parts, "<br />") or nil
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
end
 
 
local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
local statusApps = formatStatusApplications(rec["Status Applications"], suppressIdx)
local statusApps = formatStatusApplications(rec["Status Applications"], suppressIdx)
local statusRem  = formatStatusRemoval(rec["Status Removal"])
local statusRem  = formatStatusRemoval(rec["Status Removal"])
if statusApps or statusRem then
if statusApps or statusRem then
addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications")
addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications")
addRow(root, "Removes", statusRem,  "sv-row-status", "Status Removal")
addRow(root, "Removes", statusRem,  "sv-row-status", "Status Removal")
end
end
 
 
-- Events
        -- Events
local function formatEvents(list)
        local function formatEvents(list)
if type(list) ~= "table" or #list == 0 then return nil end
                if type(list) ~= "table" or #list == 0 then return nil end
local parts = {}
                local parts = {}
for _, ev in ipairs(list) do
                for _, ev in ipairs(list) do
if type(ev) == "table" then
                        if type(ev) == "table" then
local action = ev.Action or "On event"
                                local action = resolveDisplayName(ev.Action, "event") or ev.Action or "On event"
local name  = ev["Skill Internal Name"] or ev["Skill External Name"] or "Unknown skill"
                                local name  = resolveSkillNameFromEvent(ev)
table.insert(parts, string.format("%s → %s", action, name))
                                table.insert(parts, string.format("%s → %s", mw.text.nowiki(action), mw.text.nowiki(name)))
end
                        end
end
                end
return (#parts > 0) and table.concat(parts, "<br />") or nil
                return (#parts > 0) and table.concat(parts, "<br />") or nil
end
        end


local eventsText = formatEvents(rec.Events)
local eventsText = formatEvents(rec.Events)
Line 1,906: Line 2,093:
end
end


-- Notes
return tostring(root)
if type(rec.Notes) == "table" and #rec.Notes > 0 then
addRow(root, "Notes", table.concat(rec.Notes, "<br />"), "sv-row-meta", "Notes")
end
 
return tostring(root)
end
end