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
 
(18 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:GameSkills
-- Module:GameSkills
--
--
-- Renders SpiritVale skill infoboxes and class/user skill lists.
-- Phase 6.5+ (Plug-in Slot Architecture)
--
--
-- Phase 6.5+ (Plug-in Slot Architecture)
-- Layout:
-- Layout:
--  1) hero-title-bar        (TOP BAR, 2 slots: herobar 1..2)
--  Row 1: Slot 1 + Slot 2 (Icon + SkillType)
--  2) hero-description-bar  (description strip)
--  Row 2: Slot 3 + Slot 4 (Description + Placeholder)
--  3) hero-modules row      (4 slots: hero-module-1..4)
--   Row 3: Slot 5 + Slot 6 (SourceType + QuickStats)
--  Row 4: Slot 7 + Slot 8 (SpecialMechanics + LevelSelector)
--
--
-- Requires Common.js:
-- Requires Common.js:
--  - Updates .sv-dyn spans via data-series
--  - updates .sv-dyn spans via data-series
--  - Updates .sv-level-num + data-level on .sv-skill-card
--  - updates .sv-level-num + data-level on .sv-skill-card
--  - Binds to input.sv-level-range inside each card
--  - binds to input.sv-level-range inside each card


local GameData = require("Module:GameData")
local GameData = require("Module:GameData")
Line 23: Line 23:


local skillsCache
local skillsCache
local eventsCache


-- Load skills once per page parse (cached).
-- 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 36: Line 49:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- Read template args from parent if present.
-- getArgs: read args from parent frame when invoked from a template.
local function getArgs(frame)
local function getArgs(frame)
local parent = frame:getParent()
local parent = frame:getParent()
Line 42: Line 55:
end
end


-- Trim string, returning nil if empty/non-string.
-- trim: normalize strings (nil if empty).
local function trim(s)
local function trim(s)
if type(s) ~= "string" then
if type(s) ~= "string" then
Line 51: Line 64:
end
end


-- Coerce number-like values to a Lua number (supports { Value = ... }).
-- toNum: convert common scalar/table forms to a Lua number.
local function toNum(v)
local function toNum(v)
if type(v) == "number" then
if type(v) == "number" then
Line 65: Line 78:
end
end


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


-- Format numbers cleanly (integers without decimals, floats up to 4 dp).
-- fmtNum: consistent number formatting (trim trailing zeros).
local function fmtNum(n)
local function fmtNum(n)
if type(n) ~= "number" then
if type(n) ~= "number" then
Line 91: Line 104:
end
end


-- Join list to text, or nil if empty.
-- 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
end


-- Treat common “none-like” strings as empty.
-- isNoneLike: treat common "none" spellings as empty.
local function isNoneLike(v)
local function isNoneLike(v)
if v == nil then return true end
if v == nil then return true end
Line 108: Line 207:
end
end


-- Add an infobox row (label/value). Skips nil/empty values.
-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row.
local function addRow(tbl, label, value, rowClass, dataKey)
local function addRow(tbl, label, value, rowClass, dataKey)
if value == nil or value == "" then
if value == nil or value == "" then
Line 123: Line 222:
end
end


-- Format either a scalar or { Value, Unit } pair to a display string.
-- formatUnitValue: format {Value, Unit} blocks (or scalar) for display.
local function formatUnitValue(v)
local function formatUnitValue(v)
if type(v) == "table" and v.Value ~= nil then
if type(v) == "table" and v.Value ~= nil then
Line 151: Line 250:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- Build a .sv-dyn span that Common.js can update per selected level.
-- dynSpan: render a JS-updated span for a level series.
local function dynSpan(series, level)
local function dynSpan(series, level)
if type(series) ~= "table" or #series == 0 then
if type(series) ~= "table" or #series == 0 then
Line 167: Line 266:
end
end


-- True if list values are all identical (no need for a dyn span).
-- isFlatList: true if all values in list are identical.
local function isFlatList(list)
local function isFlatList(list)
if type(list) ~= "table" or #list == 0 then
if type(list) ~= "table" or #list == 0 then
Line 181: Line 280:
end
end


-- Treat value as present if not “zeroish”.
-- isNonZeroScalar: detect if a value is present and not effectively zero.
local function isNonZeroScalar(v)
local function isNonZeroScalar(v)
if v == nil then return false end
if v == nil then return false end
Line 196: Line 295:
end
end


-- Aggressive “is zero/empty” check used for hiding values.
-- isZeroish: aggressively treat common “zero” text forms as zero.
local function isZeroish(v)
local function isZeroish(v)
if v == nil then return true end
if v == nil then return true end
Line 215: Line 314:
end
end


-- Render a Base/Per Level block as a single readable string (non-dynamic).
-- valuePairRawText: render Base/Per Level blocks into readable text (fallback).
local function valuePairRawText(block)
local function valuePairRawText(block)
if type(block) ~= "table" then
if type(block) ~= "table" then
Line 249: Line 348:
end
end


-- Render only the value portion (dynamic if per-level series exists).
-- valuePairDynamicValueOnly: render Base/Per Level blocks using dyn spans where possible.
local function valuePairDynamicValueOnly(block, maxLevel, level)
local function valuePairDynamicValueOnly(block, maxLevel, level)
if type(block) ~= "table" then
if type(block) ~= "table" then
Line 285: Line 384:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- Fetch skill record by internal ID.
-- getSkillById: locate a skill by internal ID.
local function getSkillById(id)
local function getSkillById(id)
id = trim(id)
id = trim(id)
Line 293: Line 392:
end
end


-- Find skill record by display/external/internal names.
-- findSkillByName: locate a skill by external/display name.
local function findSkillByName(name)
local function findSkillByName(name)
name = trim(name)
name = trim(name)
Line 320: Line 419:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- Decide label for legacy damage basis.
-- basisLabel: label ATK/MATK basis in legacy damage blocks.
local function basisLabel(entry, isHealing)
local function basisLabel(entry, isHealing)
if isHealing then
if isHealing then
Line 340: Line 439:
end
end


-- Build dynamic legacy damage percent string for a single entry.
-- formatDamageEntry: legacy percent damage formatting (dynamic by level).
local function formatDamageEntry(entry, maxLevel, level)
local function formatDamageEntry(entry, maxLevel, level)
if type(entry) ~= "table" then
if type(entry) ~= "table" then
Line 392: Line 491:
end
end


-- Render a list of legacy damage entries (stacked).
-- formatDamageList: render a list of legacy damage entries into <br/> blocks.
local function formatDamageList(list, maxLevel, level, includeTypePrefix)
local function formatDamageList(list, maxLevel, level, includeTypePrefix)
if type(list) ~= "table" or #list == 0 then
if type(list) ~= "table" or #list == 0 then
Line 419: Line 518:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- True if a skill record belongs to a given user/class/summon/etc.
-- skillMatchesUser: check if a skill is used by a specific class/monster/summon/event.
local function skillMatchesUser(rec, userName)
local function skillMatchesUser(rec, userName)
if type(rec) ~= "table" or not userName or userName == "" then
if type(rec) ~= "table" or not userName or userName == "" then
Line 451: Line 550:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- Determine if current page is the skill’s own page (so we can hide Users rows).
-- isDirectSkillPage: hide Users rows when viewing the skill page itself.
local function isDirectSkillPage(rec)
local function isDirectSkillPage(rec)
if type(rec) ~= "table" then
if type(rec) ~= "table" then
Line 476: Line 575:
----------------------------------------------------------------------
----------------------------------------------------------------------


local HERO_BAR_SLOT_ASSIGNMENT = {
local HERO_SLOT_ASSIGNMENT = {
[1] = "IconName",
[1] = "IconName",
[2] = "SkillType",
[2] = "Description",
[3] = "LevelSelector",
[4] = "SkillType",
[5] = "SourceType",
[6] = "QuickStats",
[7] = "SpecialMechanics",
[8] = "Placeholder",
}
}


local HERO_MODULE_SLOT_ASSIGNMENT = {
----------------------------------------------------------------------
[1] = "SourceType",
[2] = "QuickStats",
[3] = "SpecialMechanics",
[4] = "LevelSelector",
}
 
----------------------------------------------------------------------
-- Slot scaffolds
-- Slot scaffolds
----------------------------------------------------------------------
----------------------------------------------------------------------


-- Render a hero-bar slot wrapper.
-- slotBox: standardized wrapper for all hero card slots.
local function heroBarBox(slot, extraClasses, innerHtml, isEmpty)
local function slotBox(slot, extraClasses, innerHtml, opts)
opts = opts or {}
 
local box = mw.html.create("div")
local box = mw.html.create("div")
box:addClass("hero-bar-module")
box:addClass("sv-slot")
box:addClass("hero-bar-module-" .. tostring(slot))
box:addClass("sv-slot--" .. tostring(slot))
box:attr("data-hero-bar-module", tostring(slot))
box:attr("data-hero-slot", tostring(slot))


if slot == 2 then
if opts.isFull then
box:addClass("sv-herobar-compact")
box:addClass("sv-slot--full")
end
end


Line 511: Line 611:
end
end


if isEmpty then
if opts.isEmpty then
box:addClass("hero-bar-module-empty")
box:addClass("sv-slot--empty")
end
end


local body = box:tag("div"):addClass("hero-bar-module-body")
local body = box:tag("div"):addClass("sv-slot__body")
if innerHtml and innerHtml ~= "" then
body:wikitext(innerHtml)
end
 
return tostring(box)
end
 
-- Render a hero-module slot wrapper (2x2 grid tiles).
local function moduleBox(slot, extraClasses, innerHtml, isEmpty)
local box = mw.html.create("div")
box:addClass("hero-module")
box:addClass("hero-module-" .. tostring(slot))
box:attr("data-hero-module", tostring(slot))
 
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 isEmpty then
box:addClass("hero-module-empty")
end
 
local body = box:tag("div"):addClass("hero-module-body")
if innerHtml and innerHtml ~= "" then
if innerHtml and innerHtml ~= "" then
body:wikitext(innerHtml)
body:wikitext(innerHtml)
Line 554: Line 627:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- Format scaling list into “X% Stat” compact lines.
-- formatScalingCompactLines: build compact “Scaling” lines for SourceType.
local function formatScalingCompactLines(scaling)
local function formatScalingCompactLines(scaling)
if type(scaling) ~= "table" then
if type(scaling) ~= "table" then
Line 587: Line 660:
end
end


-- Compute modifier word from ATK/MATK flags (used in SourceType module).
-- basisWordFromFlags: compute “Attack/Magic/Hybrid” from ATK/MATK booleans.
local function basisWordFromFlags(atkFlag, matkFlag)
local function basisWordFromFlags(atkFlag, matkFlag)
if atkFlag and matkFlag then
if atkFlag and matkFlag then
Line 599: Line 672:
end
end


-- Legacy damage percent at a given level.
-- legacyPercentAtLevel: compute “Base% + PerLevel%*level” for legacy entries.
local function legacyPercentAtLevel(entry, level)
local function legacyPercentAtLevel(entry, level)
if type(entry) ~= "table" then
if type(entry) ~= "table" then
Line 625: Line 698:
end
end


-- Expand a Base/Per Level block into a per-level display series.
-- seriesFromValuePair: normalize Base/Per Level blocks into a level-indexed series.
local function seriesFromValuePair(block, maxLevel)
local function seriesFromValuePair(block, maxLevel)
if type(block) ~= "table" then
if type(block) ~= "table" then
Line 706: Line 779:
end
end


-- Convert a per-level series into a display value (flat -> plain text, else dyn span).
-- displayFromSeries: render a series as fixed text or dynSpan (nil if all “—”).
local function displayFromSeries(series, level)
local function displayFromSeries(series, level)
if type(series) ~= "table" or #series == 0 then
if type(series) ~= "table" or #series == 0 then
Line 729: Line 802:
end
end


-- Format Area Size block into a human display string.
-- formatAreaSize: human readable area sizing for QuickStats.
local function formatAreaSize(area)
-- Shows: "<Area Size> (<number>)" e.g. "Medium (4)"
local function formatAreaSize(area, maxLevel, level)
if type(area) ~= "table" then
if type(area) ~= "table" then
return nil
return nil
end
end


local raw = area["Area Size"]
-- Helper: pull a number from scalar/unit/valuepair-ish things.
if raw == nil then
local function extractNumber(v)
return nil
if v == nil then return nil end
end


local name, num
-- Unit block {Value, Unit}
if type(v) == "table" and v.Value ~= nil then
local n = toNum(v.Value)
return n
end


if type(raw) == "table" then
-- ValuePair {Base, Per Level} -> prefer Base at current level if series exists
name = raw.Name or raw.ID or raw.Value
if type(v) == "table" and (v.Base ~= nil or v["Per Level"] ~= nil) then
num  = raw.Value
local s = seriesFromValuePair(v, maxLevel or 1)
if raw.Name or raw.ID then
if type(s) == "table" and #s > 0 then
name = raw.Name or raw.ID
local idx = clamp(level or 1, 1, #s)
local txt = s[idx]
if txt and txt ~= "—" then
-- try parse numeric from string (e.g. "4 tiles" -> 4)
local num = tonumber((mw.ustring.gsub(tostring(txt), "[^0-9%.%-]", "")))
return num
end
end
return nil
end
end
elseif type(raw) == "string" then
 
name = raw
-- Plain scalar
elseif type(raw) == "number" then
if type(v) == "number" then return v end
num = raw
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
end


if num == nil then
local sizeName = nil
num = toNum(area["Area Value"]) or toNum(area["Area Size Value"]) or toNum(area["Area Number"]) or toNum(area["Area Radius"])
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
end


if name ~= nil then
sizeName = sizeName and mw.text.trim(tostring(sizeName)) or nil
local s = mw.text.trim(tostring(name))
if not sizeName or sizeName == "" or isNoneLike(sizeName) then
if s == "" or isNoneLike(s) then
return nil
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 mw.ustring.find(s, "%(") then
return mw.text.nowiki(s)
end
if num ~= nil and num ~= 0 then
return mw.text.nowiki(string.format("%s (%s)", s, fmtNum(num)))
end
return mw.text.nowiki(s)
end
end


if num ~= nil and num ~= 0 then
if not num or num == 0 then
return mw.text.nowiki(string.format("(%s)", fmtNum(num)))
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
end


return nil
return mw.text.nowiki(sizeName)
end
end


-- True if any non-empty damage/source exists for this skill (used for hiding Damage/Element/Hits).
-- skillHasAnyDamage: determine if a skill has any meaningful damage (for non-damaging rules).
local function skillHasAnyDamage(rec, maxLevel)
local function skillHasAnyDamage(rec, maxLevel)
if type(rec.Source) == "table" then
if type(rec.Source) == "table" then
Line 803: Line 923:
end
end


-- Promote first status-application duration into QuickStats if Duration is otherwise empty (non-damaging only).
-- computeDurationPromotion: promote status-duration into QuickStats when a skill is non-damaging.
local function computeDurationPromotion(rec, maxLevel)
local function computeDurationPromotion(rec, maxLevel)
if type(rec) ~= "table" then return nil end
if type(rec) ~= "table" then return nil end
Line 850: Line 970:
local PLUGINS = {}
local PLUGINS = {}


-- Hero Bar Slot 1: Icon + Name
-- PLUGIN: IconName (Hero Bar Slot 1) - icon + name.
function PLUGINS.IconName(rec, ctx)
function PLUGINS.IconName(rec, ctx)
local icon  = rec.Icon
local icon  = rec.Icon
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 {}
 
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


if icon and icon ~= "" then
local reqStances = {}
wrap:tag("div")
for _, s in ipairs(reqStancesRaw) do
:addClass("sv-herobar-icon")
local sn = trim(s)
:wikitext(string.format("[[File:%s|80px|link=]]", icon))
if sn then table.insert(reqStances, mw.text.nowiki(sn)) end
end
end


wrap:tag("div")
local hasNotes = (#notesList > 0)
:addClass("spiritvale-infobox-title")
local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0)
:wikitext(title)


return {
inner = tostring(wrap),
classes = "module-herobar-1",
}
end


-- Hero Bar Slot 2: Skill Type (Damage/Element/Hits/Target/Cast/Combo)
local wrap = mw.html.create("div")
function PLUGINS.SkillType(rec, ctx)
wrap:addClass("sv-herobar-1-wrap")
local level = ctx.level or 1
wrap:addClass("sv-tip-scope")
local maxLevel = ctx.maxLevel or 1


local typeBlock = (type(rec.Type) == "table") and rec.Type or {}
local iconBox = wrap:tag("div")
local mech = (type(rec.Mechanics) == "table") and rec.Mechanics or {}
iconBox:addClass("sv-herobar-icon")


-- Rule: If Damage is none/non-damaging -> hide Damage, Element, Hits.
if icon and icon ~= "" then
local hideDamageElementHits = (ctx.nonDamaging == true)
iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon))
end


-- Extract a friendly display string from common { Name/ID/Value } tables.
local textBox = wrap:tag("div")
local function valName(x)
textBox:addClass("sv-herobar-text")
if x == nil then return nil end
 
if type(x) == "table" then
local titleRow = textBox:tag("div")
if x.Name and x.Name ~= "" then return tostring(x.Name) end
titleRow:addClass("sv-herobar-title-row")
if x.ID and x.ID ~= "" then return tostring(x.ID) end
 
if x.Value ~= nil then return tostring(x.Value) end
local titleBox = titleRow:tag("div")
end
titleBox:addClass("spiritvale-infobox-title")
if type(x) == "string" and x ~= "" then
titleBox:wikitext(title)
return x
 
end
if hasNotes then
return nil
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
end


-- Build “Combo” display text (Type + optional percent/duration).
if hasReq then
local function formatComboText(combo)
local pillRow = wrap:tag("div")
if combo == nil then return nil end
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


-- Sometimes data may store combo as a simple string.
if hasNotes then
if type(combo) == "string" then
local notesContent = wrap:tag("div")
local s = trim(combo)
notesContent:addClass("sv-tip-content")
return (s and not isNoneLike(s)) and mw.text.nowiki(s) 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


if type(combo) ~= "table" then
if hasReq then
return nil
local reqContent = wrap:tag("div")
end
reqContent:addClass("sv-tip-content")
reqContent:attr("data-sv-tip-content", "req")


local typ = trim(combo.Type)
if #reqSkills > 0 then
if not typ or isNoneLike(typ) then
local section = reqContent:tag("div")
return nil
section:addClass("sv-tip-section")
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Skills")
section:tag("div"):wikitext(table.concat(reqSkills, "<br />"))
end
end


local details = {}
if #reqWeapons > 0 then
 
local section = reqContent:tag("div")
local pct = formatUnitValue(combo.Percent)
section:addClass("sv-tip-section")
if pct and not isZeroish(pct) then
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Weapons")
table.insert(details, mw.text.nowiki(pct))
section:tag("div"):wikitext(table.concat(reqWeapons, ", "))
end
end


local dur = formatUnitValue(combo.Duration)
if #reqStances > 0 then
if dur and not isZeroish(dur) then
local section = reqContent:tag("div")
table.insert(details, mw.text.nowiki(dur))
section:addClass("sv-tip-section")
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Stances")
section:tag("div"):wikitext(table.concat(reqStances, ", "))
end
end
end


if #details > 0 then
return {
return mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")"
inner = tostring(wrap),
classes = "module-icon-name",
}
end
 
-- PLUGIN: SkillType (Hero Bar Slot 2) - 2 rows x 3 cells (desktop + mobile).
-- Rules:
--  - 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)
 
-- 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
end
return mw.text.nowiki(typ)
return nil
end
end


-- Compute “Hits” display (supports Base/Per Level blocks and scalars).
-- hitsDisplay: find + render Hits from multiple possible structured locations.
local function formatHitsValue(rawHits)
local function hitsDisplay()
if rawHits == nil or isNoneLike(rawHits) or isZeroish(rawHits) then
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
return nil
end
end


if type(rawHits) == "table" then
-- ValuePair-style table (Base/Per Level) => dynamic series
-- If it looks like a value-pair block, prefer series rendering.
if type(h) == "table" then
local s = seriesFromValuePair(rawHits, maxLevel)
if h.Base ~= nil or h["Per Level"] ~= nil or type(h["Per Level"]) == "table" then
local disp = displayFromSeries(s, level)
return displayFromSeries(seriesFromValuePair(h, maxLevel), level)
if disp then return disp end
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: raw rendering.
-- Fallback name extraction
local txt = valuePairDynamicValueOnly(rawHits, maxLevel, level)
local function valName(x)
return txt
if x == nil then return nil end
if type(x) == "table" then
if x.Name and x.Name ~= "" then return tostring(x.Name) end
if x.ID and x.ID ~= "" then return tostring(x.ID) end
if x.Value ~= nil then return tostring(x.Value) end
end
if type(x) == "number" then return tostring(x) end
if type(x) == "string" and x ~= "" then return x end
return nil
end
 
local vn = valName(h)
if vn and not isNoneLike(vn) then
return mw.text.nowiki(vn)
end
end
end


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


local hitsVal = nil
return nil
if not hideDamageElementHits then
hitsVal = formatHitsValue(mech.Hits or mech["Hits"])
end
end


local comboVal = formatComboText(mech.Combo or mech["Combo"])
-- 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 grid = mw.html.create("div")
local typ = trim(c.Type)
grid:addClass("sv-type-grid")
if not typ or isNoneLike(typ) then
grid:addClass("sv-compact-root")
return nil
end


local added = false
local details = {}


-- Add a grid “chunk” cell. If isHtml=true, value is already safe wikitext/HTML.
local pct = formatUnitValue(c.Percent)
local function addChunk(label, value, chunkClass, isHtml)
if pct and not isZeroish(pct) then
if value == nil or value == "" then return end
table.insert(details, mw.text.nowiki(pct))
added = true
end


local chunk = grid:tag("div"):addClass("sv-type-chunk")
local dur = formatUnitValue(c.Duration)
if chunkClass then chunk:addClass(chunkClass) end
if dur and not isZeroish(dur) then
table.insert(details, mw.text.nowiki(dur))
end


chunk:tag("div"):addClass("sv-type-label"):wikitext(mw.text.nowiki(label))
if #details > 0 then
 
return mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")"
local v = chunk:tag("div"):addClass("sv-type-value")
if isHtml then
v:wikitext(value)
else
v:wikitext(mw.text.nowiki(tostring(value)))
end
end
return mw.text.nowiki(typ)
end
end


-- Desktop order (row-major 4 columns):
local grid = mw.html.create("div")
-- Damage, Element, Hits, Target / Cast, Combo
grid:addClass("sv-type-grid")
if not hideDamageElementHits then
grid:addClass("sv-compact-root")
addChunk("Damage",  valName(typeBlock.Damage  or typeBlock["Damage Type"]),  "sv-type-chunk--damage",  false)
addChunk("Element", valName(typeBlock.Element or typeBlock["Element Type"]), "sv-type-chunk--element", false)
addChunk("Hits",    hitsVal,                                              "sv-type-chunk--hits",    true)
end


addChunk("Target", valName(typeBlock.Target or typeBlock["Target Type"]), "sv-type-chunk--target", false)
local added = false
addChunk("Cast",  valName(typeBlock.Cast  or typeBlock["Cast Type"]),  "sv-type-chunk--cast",  false)
addChunk("Combo",  comboVal,                                              "sv-type-chunk--combo",  true)


return {
-- addChunk: add one labeled value cell (key drives CSS ordering).
inner = added and tostring(grid) or "",
local function addChunk(key, label, valueHtml)
classes = "module-skill-type",
if valueHtml == nil or valueHtml == "" then return end
}
added = true
end


-- Module Tile: Level Selector (hero-module-4)
local chunk = grid:tag("div")
function PLUGINS.LevelSelector(rec, ctx)
:addClass("sv-type-chunk")
local level = ctx.level or 1
:addClass("sv-type-" .. tostring(key))
local maxLevel = ctx.maxLevel or 1
:attr("data-type-key", tostring(key))


local inner = mw.html.create("div")
chunk:tag("div")
inner:addClass("sv-level-ui")
:addClass("sv-type-label")
:wikitext(mw.text.nowiki(label))


inner:tag("div")
chunk:tag("div")
:addClass("sv-level-label")
:addClass("sv-type-value")
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
:wikitext(valueHtml)
end


local slider = inner:tag("div"):addClass("sv-level-slider")
-- 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 tonumber(maxLevel) and tonumber(maxLevel) > 1 then
if dmg and not isNoneLike(dmg) then
slider:tag("input")
addChunk("damage", "Damage", mw.text.nowiki(dmg))
:attr("type", "range")
end
:attr("min", "1")
if ele and not isNoneLike(ele) then
:attr("max", tostring(maxLevel))
addChunk("element", "Element", mw.text.nowiki(ele))
:attr("value", tostring(level))
end
:addClass("sv-level-range")
if hits then
:attr("aria-label", "Skill level select")
addChunk("hits", "Hits", hits)
else
end
inner:addClass("sv-level-ui-single")
end
slider:addClass("sv-level-slider-single")
 
-- 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
end


return {
        return {
inner = tostring(inner),
                inner = added and tostring(grid) or "",
classes = "module-level-selector",
                classes = "module-skill-type",
}
        }
end
 
-- PLUGIN: Description (Hero Slot 3) - primary description text.
function PLUGINS.Description(rec)
        local desc = trim(rec.Description)
        if not desc then
                return nil
        end
 
        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
end
end


-- Module Tile: SourceType (Modifier + Source + Scaling)
 
-- PLUGIN: SourceType (Hero Module Slot 1) - Modifier + Source + Scaling.
function PLUGINS.SourceType(rec, ctx)
function PLUGINS.SourceType(rec, ctx)
local level = ctx.level or 1
local level = ctx.level or 1
Line 1,048: Line 1,330:
local scaling    = nil
local scaling    = nil


-- Render Source value at selected level (supports per-level series).
-- sourceValueForLevel: dynamic formatting for structured Source blocks.
local function sourceValueForLevel(src)
local function sourceValueForLevel(src)
if type(src) ~= "table" then
if type(src) ~= "table" then
Line 1,139: Line 1,421:
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")


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


Line 1,180: Line 1,462:
end
end


-- Module Tile: Quick Stats (Range/Area/Cost/Cast/CD/Duration)
-- 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)
function PLUGINS.QuickStats(rec, ctx)
local level = ctx.level or 1
local level = ctx.level or 1
Line 1,192: Line 1,475:
local function dash() return "—" end
local function dash() return "—" end


-- Range: hide if 0/none-like.
-- Range (0 => —)
local rangeVal = nil
local rangeVal = nil
if mech.Range ~= nil and not isNoneLike(mech.Range) then
if mech.Range ~= nil and not isNoneLike(mech.Range) then
Line 1,208: Line 1,491:
end
end


-- Area (friendly formatting)
-- Area
local areaVal = formatAreaSize(mech.Area)
local areaVal = formatAreaSize(mech.Area, maxLevel, level)


-- Timings
-- Timings
Line 1,216: Line 1,499:
local durVal  = displayFromSeries(seriesFromValuePair(bt["Duration"],  maxLevel), level)
local durVal  = displayFromSeries(seriesFromValuePair(bt["Duration"],  maxLevel), level)


-- Promote status duration into QuickStats when needed
-- Promote status duration if needed
if (durVal == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then
if (durVal == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then
durVal = displayFromSeries(seriesFromValuePair(promo.durationBlock, maxLevel), level)
durVal = displayFromSeries(seriesFromValuePair(promo.durationBlock, maxLevel), level)
end
end


-- Cost: MP + HP (combined)
-- Cost: MP + HP
local function labeledSeries(block, label)
local function labeledSeries(block, label)
local s = seriesFromValuePair(block, maxLevel)
local s = seriesFromValuePair(block, maxLevel)
Line 1,262: Line 1,545:
grid:addClass("sv-compact-root")
grid:addClass("sv-compact-root")


-- Add one QuickStats cell.
local function addCell(label, val)
local function addCell(label, val)
local cell = grid:tag("div"):addClass("sv-m4-cell")
local cell = grid:tag("div"):addClass("sv-m4-cell")
Line 1,282: Line 1,564:
end
end


-- Module Tile: Special Mechanics (Flags + Special Effects)
-- PLUGIN: SpecialMechanics (Hero Module Slot 3)
-- NOTE: Hybrid/Self-centered/Bond/Combo/Hits are intentionally excluded here.
-- Shows:
--  - Flags (deduped)
--  - Special mechanics (mech.Effects)
-- NOTE: Combo lives in SkillType (Hero Bar Slot 2).
function PLUGINS.SpecialMechanics(rec, ctx)
function PLUGINS.SpecialMechanics(rec, ctx)
local level = ctx.level or 1
local level = ctx.level or 1
Line 1,292: Line 1,577:
local mods    = (type(rec.Modifiers) == "table") and rec.Modifiers or nil
local mods    = (type(rec.Modifiers) == "table") and rec.Modifiers or nil


-- Case-insensitive omit list for mechanics/flags we are not showing in this module.
------------------------------------------------------------------
local OMIT = {
-- Hits guard (we want Hits ONLY in SkillType)
["hybrid"] = true,
------------------------------------------------------------------
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 centered"] = true,
["self-centered"] = true,
["self-centred"] = true,
["bond"] = true,
["bond"] = true,
["combo"] = true,
["combo"] = true,
        ["hybrid"] = true,
-- hits variants
["hit"] = true,
["hits"] = true,
["hits"] = true,
["hit count"] = true,
["hits count"] = true,
["hitcount"] = true,
["hitscount"] = true,
}
}


-- True if a name should be omitted from this module.
local function allowFlag(name)
local function shouldOmit(name)
if not name then return false end
if not name then return false end
local s = mw.ustring.lower(mw.text.trim(tostring(name)))
local k = mw.ustring.lower(mw.text.trim(tostring(name)))
return (s ~= "" and OMIT[s] == true) or false
if k == "" then return false end
if denyFlags[k] then return false end
return true
end
end


------------------------------------------------------------------
-- Flags (flat, de-duped)
------------------------------------------------------------------
local flagSet = {}
-- Add boolean flags from a sub-table into a set.
local function addFlags(sub)
local function addFlags(sub)
if type(sub) ~= "table" then return end
if type(sub) ~= "table" then return end
for k, v in pairs(sub) do
for k, v in pairs(sub) do
if v and not shouldOmit(k) then
if v and allowFlag(k) then
flagSet[tostring(k)] = true
flagSet[tostring(k)] = true
end
end
Line 1,329: Line 1,636:
addFlags(mods["Special Modifiers"])
addFlags(mods["Special Modifiers"])
for k, v in pairs(mods) do
for k, v in pairs(mods) do
if type(v) == "boolean" and v and not shouldOmit(k) then
if type(v) == "boolean" and v and allowFlag(k) then
flagSet[tostring(k)] = true
flagSet[tostring(k)] = true
end
end
Line 1,340: Line 1,647:


------------------------------------------------------------------
------------------------------------------------------------------
-- Special effects (name => value)
-- Special mechanics (name => value)
------------------------------------------------------------------
------------------------------------------------------------------
local mechItems = {}
local mechItems = {}
Line 1,346: Line 1,653:
if effects then
if effects then
local keys = {}
local keys = {}
for k, _ in pairs(effects) do
for k, _ in pairs(effects) do table.insert(keys, k) end
if not shouldOmit(k) then
table.insert(keys, k)
end
end
table.sort(keys)
table.sort(keys)


for _, name in ipairs(keys) do
for _, name in ipairs(keys) do
local block = effects[name]
-- Skip Hits completely (it belongs in SkillType)
if type(block) == "table" then
if not isHitsKey(name) then
local disp = displayFromSeries(seriesFromValuePair(block, maxLevel), level)
local block = effects[name]
local t = trim(block.Type)
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 value = disp


-- If Type exists and is distinct, prefix it.
-- 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 t and not isNoneLike(t) and mw.ustring.lower(t) ~= mw.ustring.lower(tostring(name)) then
if value then
if value then
value = mw.text.nowiki(t) .. ": " .. value
value = mw.text.nowiki(t) .. ": " .. value
else
else
value = mw.text.nowiki(t)
value = mw.text.nowiki(t)
end
end
 
if value then
table.insert(mechItems, { label = tostring(name), value = value })
end
end
end
end
if value then
table.insert(mechItems, { label = tostring(name), value = value })
end
end
end
end
Line 1,396: Line 1,705:
if hasMech  then count = count + 1 end
if hasMech  then count = count + 1 end


-- Desktop:
--  1 group => centered
--  2 groups => 2 columns (flags | mechanics)
local root = mw.html.create("div")
local root = mw.html.create("div")
root:addClass("sv-sm-root")
root:addClass("sv-sm-root")
Line 1,406: Line 1,712:
layout:addClass("sv-sm-count-" .. tostring(count))
layout:addClass("sv-sm-count-" .. tostring(count))


-- Column: Flags
-- Column 1: Flags
if hasFlags then
if hasFlags then
local fcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-flags")
local fcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-flags")
Line 1,414: Line 1,720:
end
end


-- Column: Special Effects (stacked)
-- Column 2: Special Mechanics (stacked)
if hasMech then
if hasMech then
local mcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-mech")
local mcol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-mech")
Line 1,430: Line 1,736:
end
end


----------------------------------------------------------------------
-- PLUGIN: LevelSelector (Hero Module Slot 4) - JS level slider.
-- Generic slot renderers
function PLUGINS.LevelSelector(rec, ctx)
----------------------------------------------------------------------
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1


-- Normalize plugin return into { inner = string, classes = string|table|nil }.
local inner = mw.html.create("div")
local function normalizeResult(res)
inner:addClass("sv-level-ui")
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


-- Safe plugin call (pcall) to prevent infobox from breaking the page.
inner:tag("div")
local function safeCallPlugin(name, rec, ctx)
:addClass("sv-level-label")
local fn = PLUGINS[name]
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
if type(fn) ~= "function" then
return nil
end
local ok, out = pcall(fn, rec, ctx)
if not ok then
return nil
end
return normalizeResult(out)
end


-- Render one hero-bar slot via assignment table.
local slider = inner:tag("div"):addClass("sv-level-slider")
local function renderHeroBarSlot(slotIndex, rec, ctx)
local pluginName = HERO_BAR_SLOT_ASSIGNMENT[slotIndex]
if not pluginName then
return heroBarBox(slotIndex, nil, "", true)
end


local res = safeCallPlugin(pluginName, rec, ctx)
if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
if not res or not res.inner or res.inner == "" then
slider:tag("input")
return heroBarBox(slotIndex, nil, "", true)
:attr("type", "range")
:attr("min", "1")
:attr("max", tostring(maxLevel))
:attr("value", tostring(level))
:addClass("sv-level-range")
:attr("aria-label", "Skill level select")
else
inner:addClass("sv-level-ui-single")
slider:addClass("sv-level-slider-single")
end
end


return heroBarBox(slotIndex, res.classes, res.inner, false)
return {
end
inner = tostring(inner),
 
classes = "module-level-selector",
-- Render one hero-module tile via assignment table.
}
local function renderModuleSlot(slotIndex, rec, ctx)
local pluginName = HERO_MODULE_SLOT_ASSIGNMENT[slotIndex]
if not pluginName then
return moduleBox(slotIndex, nil, "", true)
end
 
local res = safeCallPlugin(pluginName, rec, ctx)
if not res or not res.inner or res.inner == "" then
return moduleBox(slotIndex, nil, "", true)
end
 
return moduleBox(slotIndex, res.classes, res.inner, false)
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- UI builders
-- Generic slot renderers
----------------------------------------------------------------------
----------------------------------------------------------------------


-- Build the hero title bar (2 slots).
-- normalizeResult: normalize plugin return values into {inner, classes}.
local function buildHeroBarUI(rec, ctx)
local function normalizeResult(res)
local bar = mw.html.create("div")
if res == nil then return nil end
bar:addClass("hero-bar-grid")
if type(res) == "string" then
bar:wikitext(renderHeroBarSlot(1, rec, ctx))
return { inner = res, classes = nil }
bar:wikitext(renderHeroBarSlot(2, rec, ctx))
end
return tostring(bar)
if type(res) == "table" then
end
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


-- Build the 2x2 hero-module grid (4 slots).
-- safeCallPlugin: pcall wrapper to prevent infobox failure on plugin errors.
local function buildHeroModulesUI(rec, ctx)
local function safeCallPlugin(name, rec, ctx)
local grid = mw.html.create("div")
        local fn = PLUGINS[name]
grid:addClass("hero-modules-grid")
        if type(fn) ~= "function" then
for slot = 1, 4 do
                return nil
grid:wikitext(renderModuleSlot(slot, rec, ctx))
        end
end
        local ok, out = pcall(fn, rec, ctx)
return tostring(grid)
        if not ok then
                return nil
        end
        return normalizeResult(out)
end
 
-- isEmptySlotContent: true when a slot has no meaningful content.
-- NOTE: JS placeholders (sv-dyn spans, slider markup) are considered content.
local function isEmptySlotContent(inner)
        if inner == nil then return true end
 
        local raw = tostring(inner)
 
        -- Guard rails for JS-injected regions.
        for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do
                if mw.ustring.find(raw, pat) then
                        return false
                end
        end
 
        local trimmed = mw.text.trim(raw)
        if trimmed == "" or trimmed == "—" then
                return true
        end
 
        local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", ""))
        return (withoutTags == "" or withoutTags == "—")
end
end


-- Add the modules grid row (single cell spanning infobox width).
-- renderHeroSlot: render a standardized hero slot by plugin assignment.
local function addHeroModulesRow(tbl, modulesUI)
local function renderHeroSlot(slotIndex, rec, ctx)
if not modulesUI or modulesUI == "" then
        local pluginName = HERO_SLOT_ASSIGNMENT[slotIndex]
return
        if not pluginName then
end
                return nil
        end


local row = tbl:tag("tr")
        local res = safeCallPlugin(pluginName, rec, ctx)
row:addClass("hero-modules-row")
        if not res or isEmptySlotContent(res.inner) then
                return nil
        end


local cell = row:tag("td")
        return {
cell:attr("colspan", 2)
                inner = res.inner,
cell:addClass("hero-modules-cell")
                classes = res.classes,
cell:wikitext(modulesUI)
        }
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Infobox builder
-- UI builders
----------------------------------------------------------------------
----------------------------------------------------------------------


-- Build a single skill infobox table.
-- buildHeroSlotsUI: build the standardized 4-row slot grid (2 columns).
local function buildHeroSlotsUI(rec, ctx)
        local grid = mw.html.create("div")
        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]]
 
                if left or right then
                        hasSlots = true
 
                        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
 
        if not hasSlots then
                return ""
        end
 
        return tostring(grid)
end
 
-- addHeroSlotsRow: add the standardized slot grid into the infobox table.
local function addHeroSlotsRow(tbl, slotsUI)
        if not slotsUI or slotsUI == "" then
                return
        end
 
        local row = tbl:tag("tr")
        row:addClass("sv-slot-row")
 
        local cell = row:tag("td")
        cell:attr("colspan", 2)
        cell:addClass("sv-slot-cell")
        cell:wikitext(slotsUI)
end
 
----------------------------------------------------------------------
-- Infobox builder
----------------------------------------------------------------------
 
-- buildInfobox: render a single skill infobox.
local function buildInfobox(rec, opts)
local function buildInfobox(rec, opts)
opts = opts or {}
opts = opts or {}
Line 1,551: Line 1,918:
}
}


-- Determine “non-damaging” for hiding Damage/Element/Hits in SkillType.
-- Non-damaging hides Damage/Element/Hits in SkillType
do
do
local dmgVal = nil
local dmgVal = nil
Line 1,563: Line 1,930:
end
end


-- Duration promotion into QuickStats (non-damaging only).
ctx.promo = computeDurationPromotion(rec, maxLevel)
ctx.promo = computeDurationPromotion(rec, maxLevel)


Line 1,581: Line 1,947:
end
end


local desc  = rec.Description or ""
-- Standardized slot grid
 
addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx))
-- Hero Title Bar
local heroRow = root:tag("tr")
heroRow:addClass("spiritvale-infobox-main")
heroRow:addClass("sv-hero-title-row")
heroRow:addClass("hero-title-bar")
 
local heroCell = heroRow:tag("th")
heroCell:attr("colspan", 2)
heroCell:addClass("sv-hero-title-cell")
heroCell:wikitext(buildHeroBarUI(rec, ctx))
 
-- Description Bar
if desc ~= "" then
local descRow = root:tag("tr")
descRow:addClass("spiritvale-infobox-main")
descRow:addClass("sv-hero-desc-row")
descRow:addClass("hero-description-bar")
 
local descCell = descRow:tag("td")
descCell:attr("colspan", 2)
descCell:addClass("sv-hero-desc-cell")
 
local descInner = descCell:tag("div")
descInner:addClass("spiritvale-infobox-main-right-inner")
 
descInner:tag("div")
:addClass("spiritvale-infobox-description")
:wikitext(string.format("''%s''", desc))
end
 
-- Modules row
addHeroModulesRow(root, buildHeroModulesUI(rec, ctx))
 
-- Users (optionally hidden on direct skill pages)
if showUsers then
local users = rec.Users or {}
addRow(root, "Classes",  listToText(users.Classes),  "sv-row-users", "Users.Classes")
addRow(root, "Summons",  listToText(users.Summons),  "sv-row-users", "Users.Summons")
addRow(root, "Monsters", listToText(users.Monsters), "sv-row-users", "Users.Monsters")
addRow(root, "Events",  listToText(users.Events),  "sv-row-users", "Users.Events")
end
 
-- Requirements
local req = rec.Requirements or {}
local hasReq =
(type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0) or
(type(req["Required Weapons"]) == "table" and #req["Required Weapons"] > 0) or
(type(req["Required Stances"]) == "table" and #req["Required Stances"] > 0)
 
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")
-- Users (hide on direct skill page)
addRow(root, "Required Stances", listToText(req["Required Stances"]), "sv-row-req", "Requirements.Required Stances")
        if showUsers then
end
                local users = rec.Users or {}
                addRow(root, "Classes", listToText(users.Classes),  "sv-row-users", "Users.Classes")
                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 only small extras here; most are in hero tiles)
        -- Mechanics (keep small extras only)
local mech = rec.Mechanics or {}
        local mech = rec.Mechanics or {}
if next(mech) ~= nil then
        if next(mech) ~= nil then
if mech["Autocast Multiplier"] ~= nil then
                if mech["Autocast Multiplier"] ~= nil then
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
                        addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
end
                end
end
        end


-- Legacy damage breakdown (only when Source absent)
-- Legacy damage breakdown (only when Source absent)
Line 1,684: Line 2,003:


-- Status rows
-- Status rows
-- Format Status Applications list into display text.
local function formatStatusApplications(list, suppressDurationIndex)
local function formatStatusApplications(list, suppressDurationIndex)
if type(list) ~= "table" or #list == 0 then return nil end
if type(list) ~= "table" or #list == 0 then return nil end
Line 1,717: Line 2,035:
end
end


-- Format Status Removal list into display text.
local function formatStatusRemoval(list)
local function formatStatusRemoval(list)
if type(list) ~= "table" or #list == 0 then return nil end
if type(list) ~= "table" or #list == 0 then return nil end
Line 1,757: Line 2,074:
end
end


-- Events
        -- Events
-- Format event triggers list into display text.
        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 = resolveDisplayName(ev.Action, "event") or ev.Action or "On event"
local action = ev.Action or "On event"
                                local name  = resolveSkillNameFromEvent(ev)
local name  = ev["Skill Internal Name"] or ev["Skill External Name"] or "Unknown skill"
                                table.insert(parts, string.format("%s → %s", mw.text.nowiki(action), mw.text.nowiki(name)))
table.insert(parts, string.format("%s → %s", action, 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,777: 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


Line 1,814: Line 2,125:
end
end


local root = mw.html.create("div")
local out = {}
root:addClass("sv-skill-collection")


for _, rec in ipairs(matches) do
for _, rec in ipairs(matches) do
local item = root:tag("div"):addClass("sv-skill-item")
local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"
item:wikitext(buildInfobox(rec, { showUsers = false, inList = true }))
 
-- List mode: emit a raw H3 heading before each standalone card so TOC/anchors work.
table.insert(out, string.format("=== %s ===", title))
 
-- List mode cards are independent (no single wrapper container).
table.insert(out, buildInfobox(rec, { showUsers = false, inList = true }))
end
end


return tostring(root)
return table.concat(out, "\n")
end
end