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
 
(66 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:GameSkills
-- Module:GameSkills
--
--
-- Renders active skill data (from Data:skills.json) into an infobox-style table
-- Phase 6.5+ (Plug-in Slot Architecture)
-- and can also list all skills for a given user/class.
--
--
-- Usage (single skill):
-- Layout:
--  {{Skill|Heal}}
--  Row 1: Slot 1 + Slot 2 (Icon + SkillType)
--  {{Skill|name=Heal}}
--  Row 2: Slot 3 + Slot 4 (Description + Placeholder)
--  {{Skill|id=Heal_InternalId}}
--  Row 3: Slot 5 + Slot 6 (SourceType + QuickStats)
--  Row 4: Slot 7 + Slot 8 (SpecialMechanics + LevelSelector)
--
--
-- Usage (auto-list on class page, e.g. "Acolyte"):
-- Requires Common.js:
--  {{Skill}}                  -> lists all Acolyte skills (page name)
--  - updates .sv-dyn spans via data-series
--  {{Skill|Acolyte}}          -> same, if no skill literally called "Acolyte"
--  - updates .sv-level-num + data-level on .sv-skill-card
--  - binds to input.sv-level-range inside each card


local GameData = require("Module:GameData")
local GameData = require("Module:GameData")
Line 18: Line 19:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Internal helpers
-- Data cache
----------------------------------------------------------------------
----------------------------------------------------------------------


local skillsCache
local skillsCache
local eventsCache


-- 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
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
----------------------------------------------------------------------
-- Small utilities
----------------------------------------------------------------------
-- 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()
    if parent then
return (parent and parent.args) or frame.args
        return parent.args
end
    end
 
    return frame.args
-- trim: normalize strings (nil if empty).
local function trim(s)
if type(s) ~= "string" then
return nil
end
s = mw.text.trim(s)
return (s ~= "" and s) or nil
end
 
-- toNum: convert common scalar/table forms to a Lua number.
local function toNum(v)
if type(v) == "number" then
return v
end
if type(v) == "string" then
return tonumber(v)
end
if type(v) == "table" and v.Value ~= nil then
return toNum(v.Value)
end
return nil
end
 
-- clamp: clamp a number into [lo, hi].
local function clamp(n, lo, hi)
if type(n) ~= "number" then
return lo
end
if n < lo then return lo end
if n > hi then return hi end
return n
end
end


-- fmtNum: consistent number formatting (trim trailing zeros).
local function fmtNum(n)
if type(n) ~= "number" then
return (n ~= nil) and tostring(n) or nil
end
if math.abs(n - math.floor(n)) < 1e-9 then
return tostring(math.floor(n))
end
local s = string.format("%.4f", n)
s = mw.ustring.gsub(s, "0+$", "")
s = mw.ustring.gsub(s, "%.$", "")
return s
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
        return nil
                return nil
    end
        end
    return table.concat(list, sep or ", ")
        return table.concat(list, sep or ", ")
end
 
local function resolveDisplayName(v, kind)
        if v == nil then return nil end
 
        local function firstString(keys, source)
                for _, key in ipairs(keys) do
                        local candidate = source[key]
                        if type(candidate) == "string" and candidate ~= "" then
                                return candidate
                        end
                end
                return nil
        end
 
        if type(v) == "table" then
                local primaryKeys = { "External Name", "Display Name", "Name" }
                local extendedKeys = { "Skill External Name", "Status External Name" }
                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
 
local function resolveEventName(v)
        local resolved = resolveDisplayName(v, "event")
        if type(resolved) == "string" then
                return resolved
        end
        return (resolved ~= nil) and tostring(resolved) or nil
end
 
local function resolveSkillNameFromEvent(ev)
        if type(ev) ~= "table" then
                return resolveDisplayName(ev, "skill") or "Unknown skill"
        end
 
        local displayKeys = {
                "Skill External Name",
                "External Name",
                "Display Name",
                "Name",
                "Skill Name",
        }
 
        for _, key in ipairs(displayKeys) do
                local candidate = resolveDisplayName(ev[key], "skill")
                if candidate then
                        return candidate
                end
        end
 
        local internalKeys = {
                "Skill Internal Name",
                "Skill ID",
                "Internal Name",
                "Internal ID",
                "ID",
        }
 
        for _, key in ipairs(internalKeys) do
                local candidate = ev[key]
                if type(candidate) == "string" and candidate ~= "" then
                        return candidate
                end
        end
 
        return "Unknown skill"
end
 
-- 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
 
-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row.
local function addRow(tbl, label, value, rowClass, dataKey)
if value == nil or value == "" then
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
 
row:tag("th"):wikitext(label):done()
row:tag("td"):wikitext(value):done()
end
 
-- formatUnitValue: format {Value, Unit} blocks (or scalar) for display.
local function formatUnitValue(v)
if type(v) == "table" and v.Value ~= nil then
local unit = v.Unit
local val  = v.Value
 
if unit == "percent_decimal" or unit == "percent_whole" or unit == "percent" then
return tostring(val) .. "%"
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
 
return (v ~= nil) and tostring(v) or nil
end
 
----------------------------------------------------------------------
-- Dynamic spans (JS-driven)
----------------------------------------------------------------------
 
-- dynSpan: render a JS-updated span for a level series.
local function dynSpan(series, level)
if type(series) ~= "table" or #series == 0 then
return nil
end
 
level = clamp(level or #series, 1, #series)
 
local span = mw.html.create("span")
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


local function addRow(tbl, label, value)
-- isFlatList: true if all values in list are identical.
    if value == nil or value == "" then
local function isFlatList(list)
        return
if type(list) ~= "table" or #list == 0 then
    end
return false
    local row = tbl:tag("tr")
end
    row:tag("th"):wikitext(label):done()
local first = tostring(list[1])
    row:tag("td"):wikitext(value):done()
for i = 2, #list do
if tostring(list[i]) ~= first then
return false
end
end
return true
end
end


local function addSectionHeader(tbl, label)
-- isNonZeroScalar: detect if a value is present and not effectively zero.
    local row = tbl:tag("tr")
local function isNonZeroScalar(v)
    local cell = row:tag("th")
if v == nil then return false end
    cell:attr("colspan", 2)
if type(v) == "number" then return v ~= 0 end
    cell:addClass("spiritvale-infobox-section-header")
if type(v) == "string" then
    cell:wikitext(label)
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
end


-- Lookup by Internal Name
-- isZeroish: aggressively treat common “zero” text forms as zero.
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
return nil
end
 
local base = block.Base
local per  = block["Per Level"]
 
if type(per) == "table" then
if #per == 0 then
return formatUnitValue(base)
end
if isFlatList(per) then
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
 
local baseText = formatUnitValue(base)
local perText  = formatUnitValue(per)
 
if baseText and perText and isNonZeroScalar(per) then
return string.format("%s (Per Level: %s)", baseText, perText)
end
 
return baseText or perText
end
 
-- valuePairDynamicValueOnly: render Base/Per Level blocks using dyn spans where possible.
local function valuePairDynamicValueOnly(block, maxLevel, level)
if type(block) ~= "table" then
return nil
end
 
local base = block.Base
local per  = block["Per Level"]
 
if type(per) == "table" then
if #per == 0 then
local baseText = formatUnitValue(base)
return baseText and mw.text.nowiki(baseText) or nil
end
 
if isFlatList(per) then
local one  = formatUnitValue(per[1]) or tostring(per[1])
local show = formatUnitValue(base) or one
return show and mw.text.nowiki(show) or nil
end
 
local series = {}
for _, v in ipairs(per) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
return dynSpan(series, level)
end
 
local txt = valuePairRawText(block)
return txt and mw.text.nowiki(txt) or nil
end
 
----------------------------------------------------------------------
-- Lookups
----------------------------------------------------------------------
 
-- getSkillById: locate a skill by internal ID.
local function getSkillById(id)
local function getSkillById(id)
    if not id or id == "" then
id = trim(id)
        return nil
if not id then return nil end
    end
local dataset = getSkills()
    local dataset = getSkills()
return (dataset.byId or {})[id]
    local byId = dataset.byId or {}
    return byId[id]
end
end


-- Lookup by display Name (for editors)
-- findSkillByName: locate a skill by external/display name.
local function findSkillByName(name)
local function findSkillByName(name)
    if not name or name == "" then
name = trim(name)
        return nil
if not name then return nil end
    end
 
    local dataset = getSkills()
local dataset = getSkills()
    for _, rec in ipairs(dataset.records or {}) do
local byName = dataset.byName or {}
        if rec["Name"] == name then
 
            return rec
if byName[name] then
        end
return byName[name]
    end
end
    return 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
 
return nil
end
 
----------------------------------------------------------------------
-- Legacy damage helpers
----------------------------------------------------------------------
 
-- 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
end
 
local isHealing = (entry.Type == "Healing")
local basis = isHealing and "Healing" or basisLabel(entry, false)
 
local baseRaw = entry["Base %"]
local perRaw  = entry["Per Level %"]
 
local baseN = toNum(baseRaw)
local perN  = toNum(perRaw)
 
local function baseIsPresent()
if baseN ~= nil then
return baseN ~= 0
end
if baseRaw ~= nil then
local s = tostring(baseRaw)
return (s ~= "" and s ~= "0" and s ~= "0.0" and s ~= "0.00")
end
return false
end
 
local baseText
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
return baseText and mw.text.nowiki(baseText .. " " .. basis) or nil
end
 
local series = {}
for lv = 1, maxLevel do
local perPart = perN * lv
 
if baseText and baseN ~= nil then
local total = baseN + perPart
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)
end
 
-- formatDamageList: render a list of legacy damage entries into <br/> blocks.
local function formatDamageList(list, maxLevel, level, includeTypePrefix)
if type(list) ~= "table" or #list == 0 then
return nil
end
 
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
 
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
 
----------------------------------------------------------------------
-- Users matching
----------------------------------------------------------------------
 
-- 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
 
local users = rec.Users
if type(users) ~= "table" then
return false
end
 
local userLower = mw.ustring.lower(userName)
 
local function listHas(list)
if type(list) ~= "table" then
return false
end
for _, v in ipairs(list) do
if type(v) == "string" and mw.ustring.lower(v) == userLower then
return true
end
end
return false
end
 
return listHas(users.Classes) or listHas(users.Summons) or listHas(users.Monsters) or listHas(users.Events)
end
 
----------------------------------------------------------------------
-- 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
 
local pageTitle = mw.title.getCurrentTitle()
local pageName  = pageTitle and pageTitle.text or ""
pageName = trim(pageName)
if not pageName then
return false
end
 
pageName = mw.ustring.lower(pageName)
 
local ext = trim(rec["External Name"] or rec.Name or rec["Display Name"])
local internal = trim(rec["Internal Name"] or rec.InternalName or rec.InternalID)
 
return (ext and mw.ustring.lower(ext) == pageName) or (internal and mw.ustring.lower(internal) == pageName) or false
end
 
----------------------------------------------------------------------
-- Slot config (edit these tables only to rearrange layout)
----------------------------------------------------------------------
 
local HERO_SLOT_ASSIGNMENT = {
[1] = "IconName",
[2] = "Description",
[3] = "LevelSelector",
[4] = "SkillType",
[5] = "SourceType",
[6] = "QuickStats",
[7] = "SpecialMechanics",
[8] = "Placeholder",
}
 
----------------------------------------------------------------------
-- Slot scaffolds
----------------------------------------------------------------------
 
-- slotBox: standardized wrapper for all hero card slots.
local function slotBox(slot, extraClasses, innerHtml, opts)
opts = opts or {}
 
local box = mw.html.create("div")
box:addClass("sv-slot")
box:addClass("sv-slot--" .. tostring(slot))
box:attr("data-hero-slot", tostring(slot))
 
if opts.isFull then
box:addClass("sv-slot--full")
end
 
if extraClasses then
if type(extraClasses) == "string" then
box:addClass(extraClasses)
elseif type(extraClasses) == "table" then
for _, c in ipairs(extraClasses) do box:addClass(c) end
end
end
 
if opts.isEmpty then
box:addClass("sv-slot--empty")
end
 
local body = box:tag("div"):addClass("sv-slot__body")
if innerHtml and innerHtml ~= "" then
body:wikitext(innerHtml)
end
 
return tostring(box)
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Formatting helpers
-- Shared helpers (Source + QuickStats + SpecialMechanics)
----------------------------------------------------------------------
----------------------------------------------------------------------


local function formatBasePer(block)
-- formatScalingCompactLines: build compact “Scaling” lines for SourceType.
    if type(block) ~= "table" then
local function formatScalingCompactLines(scaling)
        return nil
if type(scaling) ~= "table" then
    end
return {}
    local parts = {}
end
    if block.Base ~= nil then
 
        table.insert(parts, string.format("Base %s", tostring(block.Base)))
local list = scaling
    end
if #list == 0 then
    if block["Per Level"] ~= nil then
if scaling.Percent ~= nil or scaling["Scaling ID"] or scaling["Scaling Name"] then
        table.insert(parts, string.format("%s / Lv", tostring(block["Per Level"])))
list = { scaling }
    end
else
    if #parts == 0 then
return {}
        return nil
end
    end
end
    return table.concat(parts, ", ")
 
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 pctN ~= nil and pctN ~= 0 then
table.insert(out, string.format("%s%% %s", fmtNum(pctN), stat))
elseif pct ~= nil and tostring(pct) ~= "" and tostring(pct) ~= "0" then
table.insert(out, string.format("%s%% %s", tostring(pct), stat))
end
end
end
 
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
 
-- legacyPercentAtLevel: compute “Base% + PerLevel%*level” for legacy entries.
local function legacyPercentAtLevel(entry, level)
if type(entry) ~= "table" then
return nil
end
 
local baseRaw = entry["Base %"]
local perRaw  = entry["Per Level %"]
local baseN  = toNum(baseRaw)
local perN    = toNum(perRaw)
 
if perN ~= nil and perN ~= 0 then
local total = (baseN or 0) + (perN * level)
return fmtNum(total) .. "%"
end
 
if baseN ~= nil then
return fmtNum(baseN) .. "%"
end
if baseRaw ~= nil and tostring(baseRaw) ~= "" then
return tostring(baseRaw) .. "%"
end
 
return nil
end
end


local function formatMainDamage(list)
-- seriesFromValuePair: normalize Base/Per Level blocks into a level-indexed series.
    if type(list) ~= "table" or #list == 0 then
local function seriesFromValuePair(block, maxLevel)
        return nil
if type(block) ~= "table" then
    end
return nil
    local parts = {}
end
    for _, d in ipairs(list) do
 
        if type(d) == "table" then
local base = block.Base
            local kind = d.Type or "Damage"
local per  = block["Per Level"]
            local base = d["Base %"]
 
            local per = d["Per Level %"]
local function pickUnit(v)
            local seg  = kind
if type(v) == "table" and v.Unit and v.Unit ~= "" then
            local detail = {}
return v.Unit
            if base ~= nil then
end
                table.insert(detail, string.format("Base %s%%", tostring(base)))
return nil
            end
end
            if per ~= nil then
local unit = pickUnit(base) or pickUnit(per)
                table.insert(detail, string.format("%s%% / Lv", tostring(per)))
 
            end
local function fmtAny(v)
            if d["ATK-Based"] then
local t = formatUnitValue(v)
                table.insert(detail, "ATK-based")
return t and tostring(t) or (v ~= nil and tostring(v) or nil)
            end
end
            if d["MATK-Based"] then
 
                table.insert(detail, "MATK-based")
local series = {}
            end
 
            if #detail > 0 then
-- Expanded per-level series (wikiprep)
                seg = seg .. " – " .. table.concat(detail, ", ")
if type(per) == "table" and #per > 0 then
            end
for lv = 1, maxLevel do
            table.insert(parts, seg)
local raw = per[lv] or per[#per]
        end
local one = fmtAny(raw)
    end
if one == nil or isZeroish(raw) or isZeroish(one) then
    if #parts == 0 then
one = ""
        return nil
end
    end
series[lv] = one
    return table.concat(parts, "<br />")
end
return series
end
 
-- Empty per-list -> base only
if type(per) == "table" and #per == 0 then
local one = fmtAny(base)
if one == nil or isZeroish(base) or isZeroish(one) then
one = ""
end
for lv = 1, maxLevel do
series[lv] = one
end
return series
end
 
-- Scalar per -> compute base + per*level (fallback)
local baseN = toNum(base) or 0
local perN  = toNum(per)
 
if perN ~= nil then
for lv = 1, maxLevel do
local total = baseN + (perN * lv)
local v = unit and { Value = total, Unit = unit } or total
local one = fmtAny(v)
if one == nil or total == 0 or isZeroish(one) then
one = ""
end
series[lv] = one
end
return series
end
 
-- Base-only scalar
local raw = (base ~= nil) and base or per
local one = fmtAny(raw)
if one == nil then
return nil
end
if isZeroish(raw) or isZeroish(one) then
one = ""
end
for lv = 1, maxLevel do
series[lv] = one
end
return series
end
end


local function formatReflectDamage(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)
        return nil
if type(series) ~= "table" or #series == 0 then
    end
return nil
    local parts = {}
end
    for _, d in ipairs(list) do
 
        if type(d) == "table" then
local any = false
            local base = d["Base %"]
for _, v in ipairs(series) do
            local per  = d["Per Level %"]
if v ~= "" then
            local seg  = "Reflect"
any = true
            local detail = {}
break
            if base ~= nil then
end
                table.insert(detail, string.format("Base %s%%", tostring(base)))
end
            end
if not any then
            if per ~= nil then
return nil
                table.insert(detail, string.format("%s%% / Lv", tostring(per)))
end
            end
 
            if #detail > 0 then
if isFlatList(series) then
                seg = seg .. " – " .. table.concat(detail, ", ")
return mw.text.nowiki(series[1])
            end
end
            table.insert(parts, seg)
return dynSpan(series, level)
        end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, "<br />")
end
end


local function formatScaling(list)
-- formatAreaSize: human readable area sizing for QuickStats.
    if type(list) ~= "table" or #list == 0 then
-- Shows: "<Area Size> (<number>)" e.g. "Medium (4)"
        return nil
local function formatAreaSize(area, maxLevel, level)
    end
if type(area) ~= "table" then
    local parts = {}
return nil
    for _, s in ipairs(list) do
end
        if type(s) == "table" then
 
            local name = s["Scaling Name"] or s["Scaling ID"] or "Unknown"
-- Helper: pull a number from scalar/unit/valuepair-ish things.
            local pct  = s.Percent
local function extractNumber(v)
            local seg  = name
if v == nil then return nil end
            local detail = {}
 
            if pct ~= nil then
-- Unit block {Value, Unit}
                table.insert(detail, string.format("%s%%", tostring(pct)))
if type(v) == "table" and v.Value ~= nil then
            end
local n = toNum(v.Value)
            if s["ATK-Based"] then
return n
                table.insert(detail, "ATK-based")
end
            end
 
            if s["MATK-Based"] then
-- ValuePair {Base, Per Level} -> prefer Base at current level if series exists
                table.insert(detail, "MATK-based")
if type(v) == "table" and (v.Base ~= nil or v["Per Level"] ~= nil) then
            end
local s = seriesFromValuePair(v, maxLevel or 1)
            if #detail > 0 then
if type(s) == "table" and #s > 0 then
                seg = seg .. " " .. table.concat(detail, ", ")
local idx = clamp(level or 1, 1, #s)
            end
local txt = s[idx]
            table.insert(parts, seg)
if txt and txt ~= "" then
        end
-- try parse numeric from string (e.g. "4 tiles" -> 4)
    end
local num = tonumber((mw.ustring.gsub(tostring(txt), "[^0-9%.%-]", "")))
    if #parts == 0 then
return num
        return nil
end
    end
end
    return table.concat(parts, "<br />")
return nil
end
 
-- Plain scalar
if type(v) == "number" then return v end
if type(v) == "string" then
local num = tonumber((mw.ustring.gsub(mw.text.trim(v), "[^0-9%.%-]", "")))
return num
end
 
return nil
end
 
-- 1) Read Area Size label/name
local rawSize = area["Area Size"]
if rawSize == nil then
return nil
end
 
local sizeName = nil
if type(rawSize) == "table" then
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
 
sizeName = sizeName and mw.text.trim(tostring(sizeName)) or nil
if not sizeName or sizeName == "" or isNoneLike(sizeName) then
return nil
end
 
-- 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 dist = area["Area Distance"]
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
 
if not num or num == 0 then
num =
extractNumber(area["Area Value"]) or
extractNumber(area["Area Size Value"]) or
extractNumber(area["Area Number"]) or
extractNumber(area["Area Radius"])
end
 
-- 3) Render
-- If size already contains parentheses, assume it already includes the numeric.
if mw.ustring.find(sizeName, "%(") then
return mw.text.nowiki(sizeName)
end
 
if num and num ~= 0 then
return mw.text.nowiki(string.format("%s (%s)", sizeName, fmtNum(num)))
end
 
return mw.text.nowiki(sizeName)
end
end


local function formatArea(area)
-- skillHasAnyDamage: determine if a skill has any meaningful damage (for non-damaging rules).
    if type(area) ~= "table" then
local function skillHasAnyDamage(rec, maxLevel)
        return nil
if type(rec.Source) == "table" then
    end
local s = seriesFromValuePair(rec.Source, maxLevel)
    local parts = {}
if s then
    local size = area["Area Size"]
for _, v in ipairs(s) do
    if size and size ~= "" then
if v ~= "" then return true end
        table.insert(parts, "Size: " .. tostring(size))
end
    end
end
    local dist = area["Area Distance"]
end
    local eff  = area["Effective Distance"]
 
    local distText = formatBasePer(dist)
if type(rec.Damage) == "table" then
    if distText then
local dmg = rec.Damage
        table.insert(parts, "Distance: " .. distText)
for _, key in ipairs({ "Main Damage", "Flat Damage", "Reflect Damage" }) do
    end
local lst = dmg[key]
    if eff ~= nil then
if type(lst) == "table" and #lst > 0 then
        table.insert(parts, string.format("Effective: %s", tostring(eff)))
return true
    end
end
    if #parts == 0 then
end
        return nil
end
    end
 
    return table.concat(parts, "<br />")
return false
end
end


local function formatTimingBlock(bt)
-- computeDurationPromotion: promote status-duration into QuickStats when a skill is non-damaging.
    if type(bt) ~= "table" then
local function computeDurationPromotion(rec, maxLevel)
        return nil
if type(rec) ~= "table" then return nil end
    end
if skillHasAnyDamage(rec, maxLevel) then return nil end
    local parts = {}
 
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)


    local function add(name, key)
if durS ~= nil then
        local block = bt[key]
local any = false
        local txt = formatBasePer(block)
for _, v in ipairs(durS) do
        if txt then
if v ~= "—" then any = true break end
            table.insert(parts, name .. ": " .. txt)
end
        end
if any then
    end
return nil
end
end


    add("Cast Time", "Cast Time")
local apps = rec["Status Applications"]
    add("Cooldown", "Cooldown")
if type(apps) ~= "table" then return nil end
    add("Duration", "Duration")


    if bt["Effect Cast Time"] ~= nil then
for idx, app in ipairs(apps) do
        table.insert(parts, "Effect Cast Time: " .. tostring(bt["Effect Cast Time"]))
if type(app) == "table" and type(app.Duration) == "table" then
    end
local s = seriesFromValuePair(app.Duration, maxLevel)
    if bt["Damage Delay"] ~= nil then
if s then
        table.insert(parts, "Damage Delay: " .. tostring(bt["Damage Delay"]))
for _, v in ipairs(s) do
    end
if v ~= "" then
    if bt["Effect Remove Delay"] ~= nil then
return {
        table.insert(parts, "Effect Remove Delay: " .. tostring(bt["Effect Remove Delay"]))
durationBlock = app.Duration,
    end
suppressDurationIndex = idx,
}
end
end
end
end
end


    if #parts == 0 then
return nil
        return nil
    end
    return table.concat(parts, "<br />")
end
end


local function formatResourceCost(rc)
----------------------------------------------------------------------
    if type(rc) ~= "table" then
-- Plug-ins
        return nil
----------------------------------------------------------------------
    end
 
    local parts = {}
local PLUGINS = {}
    local mana = rc["Mana Cost"]
 
    local hp  = rc["Health Cost"]
-- PLUGIN: IconName (Hero Bar Slot 1) - icon + name.
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 notesList = {}
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 {}
 
local reqSkills = {}
for _, rs in ipairs(reqSkillsRaw) do
if type(rs) == "table" then
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
 
local reqWeapons = {}
for _, w in ipairs(reqWeaponsRaw) do
local wn = trim(w)
if wn then table.insert(reqWeapons, mw.text.nowiki(wn)) end
end
 
local reqStances = {}
for _, s in ipairs(reqStancesRaw) do
local sn = trim(s)
if sn then table.insert(reqStances, mw.text.nowiki(sn)) end
end
 
local hasNotes = (#notesList > 0)
local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0)
 
 
local wrap = mw.html.create("div")
wrap:addClass("sv-herobar-1-wrap")
wrap:addClass("sv-tip-scope")
 
local iconBox = wrap:tag("div")
iconBox:addClass("sv-herobar-icon")
 
if icon and icon ~= "" then
iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon))
end
 
local textBox = wrap:tag("div")
textBox:addClass("sv-herobar-text")
 
local titleRow = textBox:tag("div")
titleRow:addClass("sv-herobar-title-row")
 
local titleBox = titleRow:tag("div")
titleBox:addClass("spiritvale-infobox-title")
titleBox:wikitext(title)
 
if hasNotes then
local notesBtn = mw.html.create("span")
notesBtn:addClass("sv-tip-btn sv-tip-btn--notes")
notesBtn:attr("role", "button")
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
 
if hasNotes then
local notesContent = wrap:tag("div")
notesContent:addClass("sv-tip-content")
notesContent:attr("data-sv-tip-content", "notes")
notesContent:tag("div"):addClass("sv-tip-title"):wikitext("Notes")
notesContent:tag("div"):wikitext(table.concat(notesList, "<br />"))
end
 
if hasReq then
local reqContent = wrap:tag("div")
reqContent:addClass("sv-tip-content")
reqContent:attr("data-sv-tip-content", "req")
 
if #reqSkills > 0 then
local section = reqContent:tag("div")
section:addClass("sv-tip-section")
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Skills")
section:tag("div"):wikitext(table.concat(reqSkills, "<br />"))
end


    local manaTxt = formatBasePer(mana)
if #reqWeapons > 0 then
    if manaTxt then
local section = reqContent:tag("div")
        table.insert(parts, "MP: " .. manaTxt)
section:addClass("sv-tip-section")
    end
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Weapons")
section:tag("div"):wikitext(table.concat(reqWeapons, ", "))
end


    local hpTxt = formatBasePer(hp)
if #reqStances > 0 then
    if hpTxt then
local section = reqContent:tag("div")
        table.insert(parts, "HP: " .. hpTxt)
section:addClass("sv-tip-section")
    end
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Stances")
section:tag("div"):wikitext(table.concat(reqStances, ", "))
end
end


    if #parts == 0 then
return {
        return nil
inner = tostring(wrap),
    end
classes = "module-icon-name",
    return table.concat(parts, "<br />")
}
end
end


local function formatCombo(combo)
-- PLUGIN: SkillType (Hero Bar Slot 2) - 2 rows x 3 cells (desktop + mobile).
    if type(combo) ~= "table" then
-- Rules:
        return nil
--  - If skill is non-damaging, hide Damage/Element/Hits.
    end
--  - If Hits is empty, hide Hits.
    local parts = {}
--  - If Combo is empty, hide Combo.
    if combo.Type then
-- Ordering:
        table.insert(parts, "Type: " .. tostring(combo.Type))
--  - Desktop: Damage, Element, Hits, Target, Cast, Combo
    end
--  - Mobile:  Damage, Element, Target, Cast, Hits, Combo (CSS reorder)
    if combo.Duration ~= nil then
function PLUGINS.SkillType(rec, ctx)
        table.insert(parts, "Duration: " .. tostring(combo.Duration))
local typeBlock = (type(rec.Type) == "table") and rec.Type or {}
    end
local mech      = (type(rec.Mechanics) == "table") and rec.Mechanics or {}
    if combo.Percent ~= nil then
 
        table.insert(parts, string.format("Bonus: %s%%", tostring(combo.Percent * 100)))
local level    = ctx.level or 1
    end
local maxLevel = ctx.maxLevel or 1
    if #parts == 0 then
 
        return nil
local hideDamageBundle = (ctx.nonDamaging == true)
    end
 
    return table.concat(parts, ", ")
-- valName: extract a display string from typical {Name/ID/Value} objects.
-- 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
end
 
-- hitsDisplay: find + render Hits from multiple possible structured locations.
local function hitsDisplay()
local effects = (type(mech.Effects) == "table") and mech.Effects or {}
 
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"]
 
if h == nil or isNoneLike(h) then
return nil
end
 
-- ValuePair-style table (Base/Per Level) => dynamic series
if type(h) == "table" then
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
 
-- Scalar number/string
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
 
return nil
end
 
-- comboDisplay: render Combo as a compact text block (Type (+ details)).
local function comboDisplay()
local c = (type(mech.Combo) == "table") and mech.Combo or nil
if not c then return nil end
 
local typ = trim(c.Type)
if not typ or isNoneLike(typ) then
return nil
end
 
local details = {}
 
local pct = formatUnitValue(c.Percent)
if pct and not isZeroish(pct) then
table.insert(details, mw.text.nowiki(pct))
end
 
local dur = formatUnitValue(c.Duration)
if dur and not isZeroish(dur) then
table.insert(details, mw.text.nowiki(dur))
end
 
if #details > 0 then
return mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")"
end
return mw.text.nowiki(typ)
end
 
local grid = mw.html.create("div")
grid:addClass("sv-type-grid")
grid:addClass("sv-compact-root")
 
local added = false
 
-- addChunk: add one labeled value cell (key drives CSS ordering).
local function addChunk(key, label, valueHtml)
if valueHtml == nil or valueHtml == "" then return end
added = true
 
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))
 
chunk:tag("div")
:addClass("sv-type-value")
:wikitext(valueHtml)
end
 
-- 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()
 
if dmg and not isNoneLike(dmg) then
addChunk("damage", "Damage", mw.text.nowiki(dmg))
end
if ele and not isNoneLike(ele) then
addChunk("element", "Element", mw.text.nowiki(ele))
end
if hits then
addChunk("hits", "Hits", hits)
end
end
 
-- Target + Cast
local tgt = valName(typeBlock.Target or typeBlock["Target Type"])
local cst = valName(typeBlock.Cast  or typeBlock["Cast Type"])
 
if tgt and not isNoneLike(tgt) then
addChunk("target", "Target", mw.text.nowiki(tgt))
end
if cst and not isNoneLike(cst) then
addChunk("cast", "Cast", mw.text.nowiki(cst))
end
 
-- Combo
local combo = comboDisplay()
if combo then
addChunk("combo", "Combo", combo)
end
 
        return {
                inner = added and tostring(grid) or "",
                classes = "module-skill-type",
        }
end
end


local function formatMechanicEffects(effects)
-- PLUGIN: Description (Hero Slot 3) - primary description text.
    if type(effects) ~= "table" then
function PLUGINS.Description(rec)
         return nil
         local desc = trim(rec.Description)
    end
         if not desc then
    local parts = {}
                 return nil
    for name, block in pairs(effects) do
         if type(block) == "table" then
            local bp = formatBasePer(block)
            local seg = name
            if bp then
                 seg = seg .. " – " .. bp
            end
            table.insert(parts, seg)
         end
         end
    end
 
    if #parts == 0 then
        local body = mw.html.create("div")
        body:addClass("sv-description")
        body:wikitext(string.format("''%s''", desc))
 
        return {
                inner = tostring(body),
                classes = "module-description",
        }
end
 
-- PLUGIN: Placeholder (Hero Slot 4) - reserved/blank.
function PLUGINS.Placeholder()
         return nil
         return nil
    end
    return table.concat(parts, "<br />")
end
end


local function formatModifiers(mods)
    if type(mods) ~= "table" then
        return nil
    end
    local parts = {}


    local function collect(label, sub)
-- PLUGIN: SourceType (Hero Module Slot 1) - Modifier + Source + Scaling.
        if type(sub) ~= "table" then
function PLUGINS.SourceType(rec, ctx)
            return
local level = ctx.level or 1
        end
local maxLevel = ctx.maxLevel or 1
        local flags = {}
 
         for k, v in pairs(sub) do
local basisWord = nil
            if v then
local sourceKind = nil
                table.insert(flags, k)
local sourceVal  = nil
            end
local scaling    = nil
        end
 
        table.sort(flags)
-- sourceValueForLevel: dynamic formatting for structured Source blocks.
        if #flags > 0 then
local function sourceValueForLevel(src)
            table.insert(parts, string.format("%s: %s", label, table.concat(flags, ", ")))
if type(src) ~= "table" then
        end
return nil
    end
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
return dynSpan(series, level)
end
 
return valuePairDynamicValueOnly(src, maxLevel, level)
end
 
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)
 
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
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
local dmg = rec.Damage
scaling = scaling or dmg.Scaling
 
local main = dmg["Main Damage"]
local refl = dmg["Reflect Damage"]
local flat = dmg["Flat Damage"]
 
if type(main) == "table" and #main > 0 then
local pick = nil
for _, d in ipairs(main) do
if type(d) == "table" and d.Type ~= "Healing" then
pick = d
break
end
end
pick = pick or main[1]
 
if type(pick) == "table" then
local atkFlag  = (pick["ATK-Based"] == true)
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)
 
sourceKind = "Flat"
sourceVal  = legacyPercentAtLevel(pick, level)
end
end
 
local scalingLines = formatScalingCompactLines(scaling)
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
 
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")
 
local extra = { "skill-source-module", "module-source-type" }
table.insert(extra, hasMod and "sv-has-mod" or "sv-no-mod")
 
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
 
local wrap = mw.html.create("div")
wrap:addClass("sv-source-grid")
wrap:addClass("sv-compact-root")
 
if hasMod then
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
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
 
if hasScaling then
local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling")
scalingCol:tag("div"):addClass("sv-source-pill"):wikitext("Scaling")
 
local list = scalingCol:tag("div"):addClass("sv-scaling-list")
for _, line in ipairs(scalingLines) do
list:tag("div"):addClass("sv-scaling-item"):wikitext(mw.text.nowiki(line))
end
end
 
return {
inner = tostring(wrap),
classes = extra,
}
end
 
-- 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 mech = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
local bt  = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
local rc  = (type(mech["Resource Cost"]) == "table") and mech["Resource Cost"] or {}
 
local function dash() return "—" end
 
-- Range (0 => —)
local rangeVal = nil
if mech.Range ~= nil and not isNoneLike(mech.Range) then
local n = toNum(mech.Range)
if n ~= nil then
if n ~= 0 then
rangeVal = mw.text.nowiki(formatUnitValue(mech.Range) or tostring(mech.Range))
end
else
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 areaVal = formatAreaSize(mech.Area, maxLevel, level)
 
-- Timings
local castVal = displayFromSeries(seriesFromValuePair(bt["Cast Time"], maxLevel), level)
local cdVal  = displayFromSeries(seriesFromValuePair(bt["Cooldown"],  maxLevel), level)
local durVal  = displayFromSeries(seriesFromValuePair(bt["Duration"],  maxLevel), level)
 
-- 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
 
-- Cost: MP + HP
local function labeledSeries(block, label)
local s = seriesFromValuePair(block, maxLevel)
if not s then return nil end
local any = false
for i, v in ipairs(s) do
if v ~= "—" then
s[i] = tostring(v) .. " " .. label
any = true
else
s[i] = "—"
end
end
return any and s or nil
end
 
local mpS = labeledSeries(rc["Mana Cost"], "MP")
local hpS = labeledSeries(rc["Health Cost"], "HP")
 
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 costVal = displayFromSeries(costSeries, level)
 
local grid = mw.html.create("div")
grid:addClass("sv-m4-grid")
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
 
-- PLUGIN: SpecialMechanics (Hero Module Slot 3)
-- Shows:
--  - Flags (deduped)
--  - 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
 
------------------------------------------------------------------
-- Hits guard (we want Hits ONLY in SkillType)
------------------------------------------------------------------
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
 
------------------------------------------------------------------
-- 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 function allowFlag(name)
if not name then return false end
local k = mw.ustring.lower(mw.text.trim(tostring(name)))
if k == "" then return false end
if denyFlags[k] then return false end
return true
end
 
local function addFlags(sub)
if type(sub) ~= "table" then return end
for k, v in pairs(sub) do
if v and allowFlag(k) then
flagSet[tostring(k)] = true
end
end
end
 
if mods then
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
 
local flags = {}
for k, _ in pairs(flagSet) do table.insert(flags, k) end
table.sort(flags)
 
------------------------------------------------------------------
-- Special mechanics (name => value)
------------------------------------------------------------------
local mechItems = {}
 
if effects then
local keys = {}
for k, _ in pairs(effects) do table.insert(keys, k) end
table.sort(keys)
 
for _, name in ipairs(keys) do
-- Skip Hits completely (it belongs in SkillType)
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
 
-- If Type exists and is distinct, prefix it.
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
 
if value then
table.insert(mechItems, { label = tostring(name), value = value })
end
end
end
end
end
end
 
local hasFlags = (#flags > 0)
local hasMech  = (#mechItems > 0)
 
if (not hasFlags) and (not hasMech) then
local root = mw.html.create("div")
root:addClass("sv-sm-root")
root:addClass("sv-compact-root")
root:tag("div"):addClass("sv-sm-empty"):wikitext("No Special Mechanics")
 
return {
inner = tostring(root),
classes = "module-special-mechanics",
}
end
 
local count = 0
if hasFlags then count = count + 1 end
if hasMech  then count = count + 1 end
 
local root = mw.html.create("div")
root:addClass("sv-sm-root")
root:addClass("sv-compact-root")
 
local layout = root:tag("div"):addClass("sv-sm-layout")
layout:addClass("sv-sm-count-" .. tostring(count))
 
-- Column 1: Flags
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


    collect("Movement", mods["Movement Modifiers"])
-- Column 2: Special Mechanics (stacked)
    collect("Combat",  mods["Combat Modifiers"])
if hasMech then
    collect("Special",  mods["Special Modifiers"])
local mcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-mech")
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


    if #parts == 0 then
return {
        return nil
inner = tostring(root),
    end
classes = "module-special-mechanics",
    return table.concat(parts, "<br />")
}
end
end


local function formatStatusApplications(list)
-- PLUGIN: LevelSelector (Hero Module Slot 4) - JS level slider.
    if type(list) ~= "table" or #list == 0 then
function PLUGINS.LevelSelector(rec, ctx)
        return nil
local level = ctx.level or 1
    end
local maxLevel = ctx.maxLevel or 1
    local parts = {}
 
    for _, s in ipairs(list) do
local inner = mw.html.create("div")
        if type(s) == "table" then
inner:addClass("sv-level-ui")
            local scope = s.Scope or "Target"
 
            local name  = s["Status Name"] or s["Status ID"] or "Unknown status"
inner:tag("div")
:addClass("sv-level-label")
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))


            local seg = scope .. " " .. name
local slider = inner:tag("div"):addClass("sv-level-slider")
            local detail = {}


            local dur = s.Duration
if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
            if type(dur) == "table" then
slider:tag("input")
                local t = formatBasePer(dur)
:attr("type", "range")
                if t then
:attr("min", "1")
                    table.insert(detail, "Duration " .. t)
:attr("max", tostring(maxLevel))
                end
:attr("value", tostring(level))
            end
:addClass("sv-level-range")
:attr("aria-label", "Skill level select")
else
inner:addClass("sv-level-ui-single")
slider:addClass("sv-level-slider-single")
end


            local ch = s.Chance
return {
            if type(ch) == "table" then
inner = tostring(inner),
                local t = formatBasePer(ch)
classes = "module-level-selector",
                if t then
}
                    table.insert(detail, "Chance " .. t)
end
                end
            end


            if s["Fixed Duration"] then
----------------------------------------------------------------------
                table.insert(detail, "Fixed duration")
-- Generic slot renderers
            end
----------------------------------------------------------------------


            if #detail > 0 then
-- normalizeResult: normalize plugin return values into {inner, classes}.
                seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
local function normalizeResult(res)
            end
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


            table.insert(parts, seg)
-- safeCallPlugin: pcall wrapper to prevent infobox failure on plugin errors.
local function safeCallPlugin(name, rec, ctx)
        local fn = PLUGINS[name]
        if type(fn) ~= "function" then
                return nil
        end
        local ok, out = pcall(fn, rec, ctx)
        if not ok then
                return nil
         end
         end
    end
         return normalizeResult(out)
    if #parts == 0 then
         return nil
    end
    return table.concat(parts, "<br />")
end
end


local function formatStatusRemoval(list)
-- isEmptySlotContent: true when a slot has no meaningful content.
    if type(list) ~= "table" or #list == 0 then
-- NOTE: JS placeholders (sv-dyn spans, slider markup) are considered content.
        return nil
local function isEmptySlotContent(inner)
    end
        if inner == nil then return true end
    local parts = {}
 
    for _, r in ipairs(list) do
        local raw = tostring(inner)
        if type(r) == "table" then
 
            local names = r["Status Name"]
        -- Guard rails for JS-injected regions.
            local label
        for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do
            if type(names) == "table" then
                if mw.ustring.find(raw, pat) then
                label = table.concat(names, ", ")
                        return false
            elseif type(names) == "string" then
                 end
                label = names
        end
            else
 
                 label = "Status"
        local trimmed = mw.text.trim(raw)
            end
        if trimmed == "" or trimmed == "" then
            local bp = formatBasePer(r)
                return true
            local seg = label
            if bp then
                seg = seg .. " " .. bp
            end
            table.insert(parts, seg)
         end
         end
    end
 
    if #parts == 0 then
        local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", ""))
        return nil
        return (withoutTags == "" or withoutTags == "—")
    end
    return table.concat(parts, "<br />")
end
end


local function formatEvents(list)
-- renderHeroSlot: render a standardized hero slot by plugin assignment.
    if type(list) ~= "table" or #list == 0 then
local function renderHeroSlot(slotIndex, rec, ctx)
        return nil
        local pluginName = HERO_SLOT_ASSIGNMENT[slotIndex]
    end
        if not pluginName then
    local parts = {}
                return nil
    for _, ev in ipairs(list) do
        end
         if type(ev) == "table" then
 
            local action = ev.Action or "On event"
        local res = safeCallPlugin(pluginName, rec, ctx)
            local name  = ev["Skill Name"] or ev["Skill ID"] or "Unknown skill"
         if not res or isEmptySlotContent(res.inner) then
            local seg    = string.format("%s → %s", action, name)
                return nil
            table.insert(parts, seg)
         end
         end
    end
 
    if #parts == 0 then
         return {
         return nil
                inner = res.inner,
    end
                classes = res.classes,
    return table.concat(parts, "<br />")
        }
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- User matching (for auto lists on class pages)
-- UI builders
----------------------------------------------------------------------
----------------------------------------------------------------------


local function skillMatchesUser(rec, userName)
-- buildHeroSlotsUI: build the standardized 4-row slot grid (2 columns).
    if type(rec) ~= "table" or not userName or userName == "" then
local function buildHeroSlotsUI(rec, ctx)
         return false
        local grid = mw.html.create("div")
    end
        grid:addClass("sv-slot-grid")
 
        local slots = {}
        for slot = 1, 8 do
                slots[slot] = renderHeroSlot(slot, rec, ctx)
        end
 
         local hasSlots = false
        for _, pair in ipairs({ { 1, 2 }, { 3, 4 }, { 5, 6 }, { 7, 8 } }) do
                local left  = slots[pair[1]]
                local right = slots[pair[2]]


    local users = rec.Users
                if left or right then
    if type(users) ~= "table" then
                        hasSlots = true
        return false
    end


    local userLower = mw.ustring.lower(userName)
                        if left and right then
                                grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isEmpty = false }))
                                grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isEmpty = false }))
                        elseif left then
                                grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isFull = true }))
                        elseif right then
                                grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isFull = true }))
                        end
                end
        end


    local function listHas(list)
         if not hasSlots then
         if type(list) ~= "table" then
                return ""
            return false
         end
         end
         for _, v in ipairs(list) do
 
            if type(v) == "string" and mw.ustring.lower(v) == userLower then
         return tostring(grid)
                 return true
end
            end
 
-- addHeroSlotsRow: add the standardized slot grid into the infobox table.
local function addHeroSlotsRow(tbl, slotsUI)
        if not slotsUI or slotsUI == "" then
                 return
         end
         end
        return false
    end


    if listHas(users.Classes) then return true end
        local row = tbl:tag("tr")
    if listHas(users.Summons) then return true end
        row:addClass("sv-slot-row")
    if listHas(users.Monsters) then return true end
    if listHas(users.Events)  then return true end


    return false
        local cell = row:tag("td")
        cell:attr("colspan", 2)
        cell:addClass("sv-slot-cell")
        cell:wikitext(slotsUI)
end
end


Line 494: Line 1,902:
----------------------------------------------------------------------
----------------------------------------------------------------------


local function buildInfobox(rec)
-- buildInfobox: render a single skill infobox.
    local root = mw.html.create("table")
local function buildInfobox(rec, opts)
    root:addClass("wikitable spiritvale-skill-infobox")
opts = opts or {}
local showUsers = (opts.showUsers ~= false)


    -- ==========================================================
local maxLevel = tonumber(rec["Max Level"]) or 1
    -- Top "hero" row: icon + name (left), description (right)
if maxLevel < 1 then maxLevel = 1 end
    -- ==========================================================
local level = clamp(maxLevel, 1, maxLevel)
    local icon  = rec.Icon
    local title = rec.Name or rec["Internal Name"] or "Unknown Skill"
    local desc  = rec.Description or ""


    local headerRow = root:tag("tr")
local ctx = {
    headerRow:addClass("spiritvale-infobox-main")
maxLevel = maxLevel,
level = level,
nonDamaging = false,
promo = nil,
}


    -- Left cell: icon + name
-- Non-damaging hides Damage/Element/Hits in SkillType
    local leftCell = headerRow:tag("th")
do
    leftCell:addClass("spiritvale-infobox-main-left")
local dmgVal = nil
if type(rec.Type) == "table" then
dmgVal = rec.Type.Damage or rec.Type["Damage Type"]
if type(dmgVal) == "table" then
dmgVal = dmgVal.Name or dmgVal.ID or dmgVal.Value
end
end
ctx.nonDamaging = isNoneLike(dmgVal) or (not skillHasAnyDamage(rec, maxLevel))
end


    local leftInner = leftCell:tag("div")
ctx.promo = computeDurationPromotion(rec, maxLevel)
    leftInner:addClass("spiritvale-infobox-main-left-inner")


    if icon and icon ~= "" then
local root = mw.html.create("table")
        leftInner:wikitext(string.format("[[File:%s|80px|link=]]", icon))
root:addClass("spiritvale-skill-infobox")
    end
root:addClass("sv-skill-card")
root:attr("data-max-level", tostring(maxLevel))
root:attr("data-level", tostring(level))


    leftInner:tag("div")
if opts.inList then
        :addClass("spiritvale-infobox-title")
root:addClass("sv-skill-inlist")
        :wikitext(title)
end


    -- Right cell: italic description
local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
    local rightCell = headerRow:tag("td")
if internalId then
    rightCell:addClass("spiritvale-infobox-main-right")
root:attr("data-skill-id", internalId)
end


    local rightInner = rightCell:tag("div")
-- Standardized slot grid
    rightInner:addClass("spiritvale-infobox-main-right-inner")
addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx))


    if desc ~= "" then
-- Users (hide on direct skill page)
        rightInner:tag("div")
        if showUsers then
            :addClass("spiritvale-infobox-description")
                local users = rec.Users or {}
            :wikitext(string.format("''%s''", desc))
                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")
                do
                        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
 
        -- 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)
    -- General
if type(rec.Source) ~= "table" then
    ------------------------------------------------------------------
local dmg = rec.Damage or {}
    addSectionHeader(root, "General")
if next(dmg) ~= nil then
local main = dmg["Main Damage"]
local mainNonHeal, healOnly = {}, {}


    -- Description is now in the hero row; don't repeat it here.
if type(main) == "table" then
    -- addRow(root, "Description", rec.Description)
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


    addRow(root, "Max level", rec["Max Level"] and tostring(rec["Max Level"]))
addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
addRow(root, "Flat Damage",    formatDamageList(dmg["Flat Damage"], maxLevel, level, false),        "sv-row-source", "Damage.Flat Damage")
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


    local users = rec.Users or {}
-- Status rows
    addRow(root, "Classes",  listToText(users.Classes))
local function formatStatusApplications(list, suppressDurationIndex)
    addRow(root, "Summons",  listToText(users.Summons))
if type(list) ~= "table" or #list == 0 then return nil end
    addRow(root, "Monsters", listToText(users.Monsters))
    addRow(root, "Events",  listToText(users.Events))


    ------------------------------------------------------------------
local parts = {}
    -- Requirements
for idx, s in ipairs(list) do
    ------------------------------------------------------------------
if type(s) == "table" then
    local req = rec.Requirements or {}
local typ  = s.Type or s.Scope or "Target"
    if (req["Required Skills"] and #req["Required Skills"] > 0)
local name = s["Status External Name"] or s["Status Internal Name"] or "Unknown status"
        or (req["Required Weapons"] and #req["Required Weapons"] > 0)
local seg = tostring(typ) .. " " .. tostring(name)
        or (req["Required Stances"] and #req["Required Stances"] > 0) then
local detail = {}


        addSectionHeader(root, "Requirements")
if idx ~= suppressDurationIndex and type(s.Duration) == "table" then
local t = valuePairDynamicValueOnly(s.Duration, maxLevel, level)
if t then table.insert(detail, "Duration: " .. t) end
end


        if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
if type(s.Chance) == "table" then
            local skillParts = {}
local t = valuePairDynamicValueOnly(s.Chance, maxLevel, level)
            for _, rs in ipairs(req["Required Skills"]) do
if t then table.insert(detail, "Chance: " .. t) end
                local name  = rs["Skill Name"] or rs["Skill ID"] or "Unknown"
end
                 local level = rs["Required Level"]
 
                 if level then
if #detail > 0 then
                    table.insert(skillParts, string.format("%s (Lv.%s)", name, level))
seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
                else
end
                    table.insert(skillParts, name)
 
table.insert(parts, seg)
end
end
 
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
 
local function formatStatusRemoval(list)
if type(list) ~= "table" or #list == 0 then return nil end
 
local parts = {}
for _, r in ipairs(list) do
if type(r) == "table" then
local names = r["Status External Name"]
local label
 
if type(names) == "table" then
label = table.concat(names, ", ")
elseif type(names) == "string" then
label = names
else
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
table.insert(parts, seg)
end
end
 
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
 
local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
local statusApps = formatStatusApplications(rec["Status Applications"], suppressIdx)
local statusRem  = formatStatusRemoval(rec["Status Removal"])
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 function formatEvents(list)
                 if type(list) ~= "table" or #list == 0 then return nil end
                local parts = {}
                 for _, ev in ipairs(list) do
                        if type(ev) == "table" then
                                local action = resolveDisplayName(ev.Action, "event") or ev.Action or "On event"
                                local name  = resolveSkillNameFromEvent(ev)
                                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
            addRow(root, "Required skills", table.concat(skillParts, ", "))
         end
         end


        addRow(root, "Required weapons", listToText(req["Required Weapons"]))
local eventsText = formatEvents(rec.Events)
        addRow(root, "Required stances", listToText(req["Required Stances"]))
if eventsText then
    end
addRow(root, "Triggers", eventsText, "sv-row-meta", "Events")
end


    ------------------------------------------------------------------
return tostring(root)
    -- Type
end
    ------------------------------------------------------------------
    local typeBlock = rec.Type or {}
    if next(typeBlock) ~= nil then
        addSectionHeader(root, "Type")


        local dt = typeBlock["Damage Type"]
----------------------------------------------------------------------
        if type(dt) == "table" and dt.Name then
-- Public: list all skills for a given user/class
            addRow(root, "Damage type", dt.Name)
----------------------------------------------------------------------
        end


        local et = typeBlock["Element Type"]
function p.listForUser(frame)
        if type(et) == "table" and et.Name then
local args = getArgs(frame)
            addRow(root, "Element", et.Name)
        end


        local tt = typeBlock["Target Type"]
local userName = args.user or args[1]
        if type(tt) == "table" and tt.Name then
if not userName or userName == "" then
            addRow(root, "Target", tt.Name)
userName = mw.title.getCurrentTitle().text
        end
end


        local ct = typeBlock["Cast Type"]
if not userName or userName == "" then
        if type(ct) == "table" and ct.Name then
return "<strong>No user name provided to Skill list.</strong>"
            addRow(root, "Cast type", ct.Name)
end
        end
    end


    ------------------------------------------------------------------
local dataset = getSkills()
    -- Mechanics
local matches = {}
    ------------------------------------------------------------------
    local mech = rec.Mechanics or {}
    if next(mech) ~= nil then
        addSectionHeader(root, "Mechanics")


        if mech.Range ~= nil then
for _, rec in ipairs(dataset.records or {}) do
            addRow(root, "Range", tostring(mech.Range))
if skillMatchesUser(rec, userName) then
        end
table.insert(matches, rec)
end
end


        local areaText = formatArea(mech.Area)
if #matches == 0 then
        addRow(root, "Area", areaText)
return string.format("<strong>No skills found for:</strong> %s", mw.text.nowiki(userName))
end


        if mech["Autocast Multiplier"] ~= nil then
local out = {}
            addRow(root, "Autocast multiplier", tostring(mech["Autocast Multiplier"]))
        end


        local btText = formatTimingBlock(mech["Basic Timings"])
for _, rec in ipairs(matches) do
        addRow(root, "Timing", btText)
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"


        local rcText = formatResourceCost(mech["Resource Cost"])
-- List mode: emit a raw H3 heading before each standalone card so TOC/anchors work.
        addRow(root, "Resource cost", rcText)
table.insert(out, string.format("=== %s ===", title))


        local comboText = formatCombo(mech.Combo)
-- List mode cards are independent (no single wrapper container).
        addRow(root, "Combo", comboText)
table.insert(out, buildInfobox(rec, { showUsers = false, inList = true }))
end


        local effText = formatMechanicEffects(mech.Effects)
return table.concat(out, "\n")
        addRow(root, "Special mechanics", effText)
end
    end


    ------------------------------------------------------------------
----------------------------------------------------------------------
    -- Damage & Healing
-- Public: single-skill or auto-list dispatcher
    ------------------------------------------------------------------
----------------------------------------------------------------------
    local dmg = rec.Damage or {}
    if next(dmg) ~= nil then
        addSectionHeader(root, "Damage and scaling")


        if dmg["Healing Present"] then
function p.infobox(frame)
            addRow(root, "Healing", "Yes")
local args = getArgs(frame)
        end


        local mainText = formatMainDamage(dmg["Main Damage"])
local raw1 = args[1]
        addRow(root, "Main damage", mainText)
local name = args.name or raw1
local id  = args.id


        local reflText = formatReflectDamage(dmg["Reflect Damage"])
local rec
        addRow(root, "Reflect damage", reflText)
if name and name ~= "" then
rec = findSkillByName(name)
end
if not rec and id and id ~= "" then
rec = getSkillById(id)
end


        local scaleText = formatScaling(dmg.Scaling)
if not rec then
        addRow(root, "Scaling", scaleText)
local pageTitle = mw.title.getCurrentTitle()
    end
local pageName  = pageTitle and pageTitle.text or ""


    ------------------------------------------------------------------
local noExplicitArgs =
    -- Modifiers
(not raw1 or raw1 == "") and
    ------------------------------------------------------------------
(not args.name or args.name == "") and
    local modsText = formatModifiers(rec.Modifiers)
(not id or id == "")
    if modsText then
        addSectionHeader(root, "Modifiers")
        addRow(root, "Flags", modsText)
    end


    ------------------------------------------------------------------
if noExplicitArgs then
    -- Status
return p.listForUser(frame)
    ------------------------------------------------------------------
end
    local statusApps = formatStatusApplications(rec["Status Applications"])
    local statusRem  = formatStatusRemoval(rec["Status Removal"])
    if statusApps or statusRem then
        addSectionHeader(root, "Status effects")
        addRow(root, "Applies", statusApps)
        addRow(root, "Removes", statusRem)
    end


    ------------------------------------------------------------------
if name and name ~= "" and name == pageName and (not id or id == "") then
    -- Events
return p.listForUser(frame)
    ------------------------------------------------------------------
end
    local eventsText = formatEvents(rec.Events)
    if eventsText then
        addSectionHeader(root, "Events")
        addRow(root, "Triggers", eventsText)
    end


    ------------------------------------------------------------------
local label = name or id or "?"
    -- Notes
return string.format(
    ------------------------------------------------------------------
"<strong>Unknown Skill:</strong> %s[[Category:Pages with unknown skill|%s]]",
    if type(rec.Notes) == "table" and #rec.Notes > 0 then
mw.text.nowiki(label),
        addSectionHeader(root, "Notes")
label
        addRow(root, "Notes", table.concat(rec.Notes, "<br />"))
)
    end
end


    return tostring(root)
local showUsers = not isDirectSkillPage(rec)
return buildInfobox(rec, { showUsers = showUsers })
end
end
return p