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
 
(22 intermediate revisions by the same user not shown)
Line 3: Line 3:
-- Phase 6.5+ (Plug-in Slot Architecture)
-- Phase 6.5+ (Plug-in Slot Architecture)
--
--
-- Standard Hero Layout (reused across the wiki):
-- Layout:
--  1) hero-title-bar        (TOP BAR, 2 slots: herobar 1..2)
--  Row 1: Slot 1 + Slot 2 (Icon + SkillType)
--        - Herobar 1: Icon + Skill Name (left)
--   Row 2: Slot 3 + Slot 4 (Description + Placeholder)
--       - Herobar 2: Reserved for compact info (right)
--  Row 3: Slot 5 + Slot 6 (SourceType + QuickStats)
--  2) hero-description-bar  (description strip)  [special-cased for now]
--   Row 4: Slot 7 + Slot 8 (SpecialMechanics + LevelSelector)
--  3) hero-modules row      (4 slots: hero-module-1..4)
--        - Slot 1: Level Selector
--        - Slot 2: Skill Type
--        - Slot 3: SourceType (Modifier + Source + Scaling) OR blank
--       - Slot 4: Quick Stats (Range/Area/Cost/Cast/Cooldown/Duration)
--
--
-- Requires Common.js logic that updates:
-- Requires Common.js:
--  - .sv-dyn spans via data-series
--  - updates .sv-dyn spans via data-series
--  - .sv-level-num + data-level on .sv-skill-card
--  - updates .sv-level-num + data-level on .sv-skill-card
--  - binds to input.sv-level-range inside each card
--  - binds to input.sv-level-range inside each card
--
-- Upgrade: shared slot wrapper classes
--  - .sv-hero-slot and .sv-hero-slot-body are applied to BOTH:
--      * hero-bar-module (hero bar)
--      * hero-module    (tile grid)
--  This allows "real" modules (SkillType/SourceType/QuickStats/etc.) to be moved
--  between slots without CSS refactors.


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


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
 
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 47: Line 49:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 52: Line 55:
end
end


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


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


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


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


-- listToText: join an array into a readable string.
local function listToText(list, sep)
local function listToText(list, sep)
if type(list) ~= "table" or #list == 0 then
        if type(list) ~= "table" or #list == 0 then
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


-- 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 112: Line 207:
end
end


local function kebabCase(s)
-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row.
if type(s) ~= "string" then
return nil
end
-- Handle "ABc" boundaries, then "aB" boundaries
s = mw.ustring.gsub(s, "([A-Z]+)([A-Z][a-z])", "%1-%2")
s = mw.ustring.gsub(s, "([a-z0-9])([A-Z])", "%1-%2")
s = mw.ustring.lower(s)
return s
end
 
-- Add a labeled infobox row (with optional hooks for future)
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 131: Line 215:
local row = tbl:tag("tr")
local row = tbl:tag("tr")
row:addClass("sv-row")
row:addClass("sv-row")
if rowClass then
if rowClass then row:addClass(rowClass) end
row:addClass(rowClass)
if dataKey then row:attr("data-field", dataKey) end
end
if dataKey then
row:attr("data-field", dataKey)
end


row:tag("th"):wikitext(label):done()
row:tag("th"):wikitext(label):done()
Line 142: Line 222:
end
end


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


-- 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 186: Line 266:
end
end


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


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


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


-- Base/Per Level renderer:
-- valuePairRawText: render Base/Per Level blocks into readable text (fallback).
-- - Per Level list -> dynamic span (or single value if flat)
-- - Per Level scalar -> "Base" + "Per Level" lines
local function valuePairDynamicLines(name, block, maxLevel, level)
if type(block) ~= "table" then
return {}
end
 
local base = block.Base
local per  = block["Per Level"]
 
-- Per Level list (expanded by wikiprep)
if type(per) == "table" then
if #per == 0 then
local baseText = formatUnitValue(base)
return baseText and { string.format("%s: %s", name, mw.text.nowiki(baseText)) } or {}
end
 
if isFlatList(per) then
local baseText = formatUnitValue(base)
local one = formatUnitValue(per[1]) or tostring(per[1])
local show = baseText or one
return show and { string.format("%s: %s", name, mw.text.nowiki(show)) } or {}
end
 
local series = {}
for _, v in ipairs(per) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
 
local dyn = dynSpan(series, level)
return dyn and { string.format("%s: %s", name, dyn) } or {}
end
 
-- scalar Per Level
local lines = {}
local baseText = formatUnitValue(base)
local perText  = formatUnitValue(per)
 
if baseText then
table.insert(lines, string.format("%s: %s", name, mw.text.nowiki(baseText)))
end
if perText and isNonZeroScalar(per) then
table.insert(lines, string.format("%s Per Level: %s", name, mw.text.nowiki(perText)))
end
 
return lines
end
 
local function valuePairDynamicText(name, block, maxLevel, level, sep)
local lines = valuePairDynamicLines(name, block, maxLevel, level)
return (#lines > 0) and table.concat(lines, sep or "<br />") or nil
end
 
local function valuePairRawText(block)
local function valuePairRawText(block)
if type(block) ~= "table" then
if type(block) ~= "table" then
Line 324: Line 348:
end
end


-- Prefer “value only” (dynSpan when series list 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 360: Line 384:
----------------------------------------------------------------------
----------------------------------------------------------------------


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


-- findSkillByName: locate a skill by external/display name.
local function findSkillByName(name)
local function findSkillByName(name)
name = trim(name)
name = trim(name)
if not name then
if not name then return nil end
return nil
end


local dataset = getSkills()
local dataset = getSkills()
Line 394: Line 416:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Formatting helpers (legacy + mechanics/status/etc.)
-- Legacy damage helpers
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 416: Line 439:
end
end


-- 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 467: Line 491:
end
end


-- 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 483: Line 508:
end
end
end
end
end
end
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
local function formatCombo(combo)
if type(combo) ~= "table" then
return nil
end
local parts = {}
if combo.Type then
table.insert(parts, "Type: " .. mw.text.nowiki(tostring(combo.Type)))
end
local durText = formatUnitValue(combo.Duration)
if durText then
table.insert(parts, "Duration: " .. mw.text.nowiki(durText))
end
if combo.Percent ~= nil then
local pctText = formatUnitValue(combo.Percent)
if pctText then
table.insert(parts, "Bonus: " .. mw.text.nowiki(pctText))
end
end
return (#parts > 0) and table.concat(parts, ", ") or nil
end
local function formatMechanicEffects(effects, maxLevel, level)
if type(effects) ~= "table" then
return nil
end
local keys = {}
for k, _ in pairs(effects) do
table.insert(keys, k)
end
table.sort(keys)
local parts = {}
local function effectAmount(block)
if type(block) ~= "table" then
return nil
end
local per = block["Per Level"]
if type(per) == "table" and #per > 0 then
if isFlatList(per) then
return mw.text.nowiki(formatUnitValue(per[1]) or tostring(per[1]))
end
local series = {}
for _, v in ipairs(per) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
return dynSpan(series, level)
end
local txt = valuePairRawText(block)
return txt and mw.text.nowiki(txt) or nil
end
for _, name in ipairs(keys) do
local block = effects[name]
if type(block) == "table" then
local t = block.Type
if t ~= nil and tostring(t) ~= "" then
local amt = effectAmount(block)
local seg = mw.text.nowiki(tostring(t) .. " - " .. tostring(name))
if amt then
seg = seg .. " + " .. amt
end
table.insert(parts, seg)
else
local txt = valuePairDynamicText(name, block, maxLevel, level, ", ")
if txt then
table.insert(parts, txt)
end
end
end
end
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
local function formatModifiers(mods)
if type(mods) ~= "table" then
return nil
end
local parts = {}
local function collect(label, sub)
if type(sub) ~= "table" then
return
end
local flags = {}
for k, v in pairs(sub) do
if v then
table.insert(flags, k)
end
end
table.sort(flags)
if #flags > 0 then
table.insert(parts, string.format("%s: %s", label, table.concat(flags, ", ")))
end
end
collect("Movement", mods["Movement Modifiers"])
collect("Combat",  mods["Combat Modifiers"])
collect("Special",  mods["Special Modifiers"])
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
local function formatStatusApplications(list, maxLevel, level, suppressDurationIndex)
if type(list) ~= "table" or #list == 0 then
return nil
end
local parts = {}
for idx, s in ipairs(list) do
if type(s) == "table" then
local typ  = s.Type or s.Scope or "Target"
local name = s["Status External Name"] or s["Status Internal Name"] or "Unknown status"
local seg = tostring(typ) .. " – " .. tostring(name)
local detail = {}
-- Duration (optionally suppressed if QuickStats promoted it)
if idx ~= suppressDurationIndex and type(s.Duration) == "table" then
local t = valuePairDynamicText("Duration", s.Duration, maxLevel, level, "; ")
if t then table.insert(detail, t) end
end
if type(s.Chance) == "table" then
local t = valuePairDynamicText("Chance", s.Chance, maxLevel, level, "; ")
if t then table.insert(detail, t) end
end
if #detail > 0 then
seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
end
table.insert(parts, seg)
end
end
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
local function formatStatusRemoval(list, maxLevel, level)
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
if type(r["Per Level"]) == "table" and #r["Per Level"] > 0 and not isFlatList(r["Per Level"]) then
local series = {}
for _, v in ipairs(r["Per Level"]) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
amt = dynSpan(series, level)
else
amt = valuePairRawText(r)
amt = amt and mw.text.nowiki(amt) or nil
end
local seg = mw.text.nowiki(label)
if amt then
seg = seg .. " – " .. amt
end
table.insert(parts, seg)
end
end
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
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 = ev.Action or "On event"
local name  = ev["Skill Internal Name"] or ev["Skill External Name"] or "Unknown skill"
table.insert(parts, string.format("%s → %s", action, name))
end
end
end
end
Line 701: Line 515:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- User matching (for auto lists on class pages)
-- Users matching
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 732: Line 547:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Direct page detection (hide Users on the skill's own page)
-- Hide Users on direct skill pages
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 756: Line 572:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Plug-in slot config (EDIT THESE TABLES ONLY to rearrange layout)
-- Slot config (edit these tables only to rearrange layout)
----------------------------------------------------------------------
----------------------------------------------------------------------


-- Hero bar: 2 slots
local HERO_SLOT_ASSIGNMENT = {
local HERO_BAR_SLOT_ASSIGNMENT = {
[1] = "IconName",
[1] = "IconName",
[2] = "SkillType",
[2] = "Description",
}
[3] = "LevelSelector",
 
[4] = "SkillType",
-- Modules row: 4 slots
[5] = "SourceType",
local HERO_MODULE_SLOT_ASSIGNMENT = {
[6] = "QuickStats",
[1] = "SourceType",
[7] = "SpecialMechanics",
[2] = "QuickStats",
[8] = "Placeholder",
[3] = "ReservedInfo",
[4] = "LevelSelector",
}
}


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Slot scaffolds (stable containers; plug-ins provide inner content)
-- Slot scaffolds
----------------------------------------------------------------------
----------------------------------------------------------------------


local function heroBarBox(slot, pluginName, extraClasses, innerHtml, isEmpty)
-- slotBox: standardized wrapper for all hero card slots.
local box = mw.html.create("div")
local function slotBox(slot, extraClasses, innerHtml, opts)
box:addClass("hero-bar-module")
opts = opts or {}
box:addClass("hero-bar-module-" .. tostring(slot))
box:attr("data-hero-bar-module", tostring(slot))
 
-- Shared cross-slot hooks
box:addClass("sv-hero-slot")
box:addClass("sv-hero-slot-bar")
 
if pluginName then
local k = kebabCase(pluginName)
if k then
box:attr("data-plugin", k)
box:addClass("sv-plugin-" .. k)
end
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 isEmpty then
box:addClass("hero-bar-module-empty")
end
 
local body = box:tag("div"):addClass("hero-bar-module-body"):addClass("sv-hero-slot-body")
if innerHtml and innerHtml ~= "" then
body:wikitext(innerHtml)
end


return tostring(box)
end
local function moduleBox(slot, pluginName, extraClasses, innerHtml, isEmpty)
local box = mw.html.create("div")
local box = mw.html.create("div")
box:addClass("hero-module")
box:addClass("sv-slot")
box:addClass("hero-module-" .. tostring(slot))
box:addClass("sv-slot--" .. tostring(slot))
box:attr("data-hero-module", tostring(slot))
box:attr("data-hero-slot", tostring(slot))


-- Shared cross-slot hooks
if opts.isFull then
box:addClass("sv-hero-slot")
box:addClass("sv-slot--full")
box:addClass("sv-hero-slot-tile")
 
if pluginName then
local k = kebabCase(pluginName)
if k then
box:attr("data-plugin", k)
box:addClass("sv-plugin-" .. k)
end
end
end


Line 839: Line 607:
box:addClass(extraClasses)
box:addClass(extraClasses)
elseif type(extraClasses) == "table" then
elseif type(extraClasses) == "table" then
for _, c in ipairs(extraClasses) do
for _, c in ipairs(extraClasses) do box:addClass(c) end
box:addClass(c)
end
end
end
end
end


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


local body = box:tag("div"):addClass("hero-module-body"):addClass("sv-hero-slot-body")
local body = box:tag("div"):addClass("sv-slot__body")
if innerHtml and innerHtml ~= "" then
if innerHtml and innerHtml ~= "" then
body:wikitext(innerHtml)
body:wikitext(innerHtml)
Line 858: Line 624:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Shared helpers used by plug-ins (Source + QuickStats)
-- Shared helpers (Source + QuickStats + SpecialMechanics)
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 893: Line 660:
end
end


-- 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 904: Line 672:
end
end


local function sourceValueForLevel(src, maxLevel, level)
-- legacyPercentAtLevel: compute “Base% + PerLevel%*level” for legacy entries.
if type(src) ~= "table" then
return nil
end
 
local per = src["Per Level"]
if type(per) == "table" and #per > 0 then
if isFlatList(per) then
local one  = formatUnitValue(per[1]) or tostring(per[1])
local show = formatUnitValue(src.Base) or one
return show and mw.text.nowiki(show) or nil
end
 
local series = {}
for _, v in ipairs(per) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
return dynSpan(series, level)
end
 
return valuePairDynamicValueOnly(src, maxLevel, level)
end
 
local function legacyPercentAtLevel(entry, level)
local function legacyPercentAtLevel(entry, level)
if type(entry) ~= "table" then
if type(entry) ~= "table" then
Line 952: Line 698:
end
end


-- 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 975: Line 722:
local series = {}
local series = {}


-- Preferred: expanded series (wikiprep)
-- Expanded per-level series (wikiprep)
if type(per) == "table" and #per > 0 then
if type(per) == "table" and #per > 0 then
for lv = 1, maxLevel do
for lv = 1, maxLevel do
Line 1,032: Line 779:
end
end


-- 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 1,054: Line 802:
end
end


local function formatAreaSize(area)
-- formatAreaSize: human readable area sizing for QuickStats.
-- 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)
if v == nil then return nil end
 
-- Unit block {Value, Unit}
if type(v) == "table" and v.Value ~= nil then
local n = toNum(v.Value)
return n
end
 
-- ValuePair {Base, Per Level} -> prefer Base at current level if series exists
if type(v) == "table" and (v.Base ~= nil or v["Per Level"] ~= nil) then
local s = seriesFromValuePair(v, maxLevel or 1)
if type(s) == "table" and #s > 0 then
local idx = clamp(level or 1, 1, #s)
local txt = s[idx]
if txt and txt ~= "—" then
-- try parse numeric from string (e.g. "4 tiles" -> 4)
local num = tonumber((mw.ustring.gsub(tostring(txt), "[^0-9%.%-]", "")))
return num
end
end
return nil
end
 
-- 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
return nil
end
end


local name, num
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


if type(raw) == "table" then
sizeName = sizeName and mw.text.trim(tostring(sizeName)) or nil
name = raw.Name or raw.ID or raw.Value
if not sizeName or sizeName == "" or isNoneLike(sizeName) then
num = raw.Value
return nil
if raw.Name or raw.ID then
end
name = raw.Name or raw.ID
 
-- 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
elseif type(raw) == "string" then
name = raw
elseif type(raw) == "number" then
num = raw
end
end


if num == nil then
if not num or num == 0 then
num = toNum(area["Area Value"]) or toNum(area["Area Size Value"]) or toNum(area["Area Number"]) or toNum(area["Area Radius"])
num =
extractNumber(area["Area Value"]) or
extractNumber(area["Area Size Value"]) or
extractNumber(area["Area Number"]) or
extractNumber(area["Area Radius"])
end
end


if name ~= nil then
-- 3) Render
local s = mw.text.trim(tostring(name))
-- If size already contains parentheses, assume it already includes the numeric.
if s == "" or isNoneLike(s) then
if mw.ustring.find(sizeName, "%(") then
return nil
return mw.text.nowiki(sizeName)
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 num and num ~= 0 then
return mw.text.nowiki(string.format("(%s)", fmtNum(num)))
return mw.text.nowiki(string.format("%s (%s)", sizeName, fmtNum(num)))
end
end


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


-- 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 1,126: Line 923:
end
end


-- computeDurationPromotion: promote status-duration into QuickStats when a skill is non-damaging.
local function computeDurationPromotion(rec, maxLevel)
local function computeDurationPromotion(rec, maxLevel)
-- Only:
-- - non-damaging skills
-- - AND missing/blank Duration in Basic Timings
-- - AND a Status Application has a Duration block
-- Then: promote that status duration into QuickStats and suppress it in Applies row.
if type(rec) ~= "table" then return nil end
if type(rec) ~= "table" then return nil end
if skillHasAnyDamage(rec, maxLevel) then return nil end
if skillHasAnyDamage(rec, maxLevel) then return nil end
Line 1,148: Line 941:
end
end
end
end
-- if durS is nil OR all "—", allow promotion


local apps = rec["Status Applications"]
local apps = rec["Status Applications"]
Line 1,173: Line 965:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Plug-ins (name -> { inner = "...", classes = "..."/{...} } OR nil/"")
-- Plug-ins
----------------------------------------------------------------------
----------------------------------------------------------------------


local PLUGINS = {}
local PLUGINS = {}


-- Hero Bar Slot 1: Icon + Name  (EXCEPTION: not required to be "portable")
-- 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 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")
local wrap = mw.html.create("div")
wrap:addClass("sv-herobar-1-wrap")
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
if icon and icon ~= "" then
wrap:tag("div")
iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon))
:addClass("sv-herobar-icon")
:wikitext(string.format("[[File:%s|80px|link=]]", icon))
end
end


wrap:tag("div")
local textBox = wrap:tag("div")
:addClass("spiritvale-infobox-title")
textBox:addClass("sv-herobar-text")
:wikitext(title)
 
local titleRow = textBox:tag("div")
titleRow:addClass("sv-herobar-title-row")
 
local titleBox = titleRow:tag("div")
titleBox:addClass("spiritvale-infobox-title")
titleBox:wikitext(title)


return {
if hasNotes then
inner = tostring(wrap),
local notesBtn = mw.html.create("span")
classes = "module-herobar-1",
notesBtn:addClass("sv-tip-btn sv-tip-btn--notes")
}
notesBtn:attr("role", "button")
end
notesBtn:attr("tabindex", "0")
notesBtn:attr("data-sv-tip", "notes")
notesBtn:attr("aria-label", "Notes")
notesBtn:attr("aria-expanded", "false")
notesBtn:tag("span"):addClass("sv-ico sv-ico--info"):attr("aria-hidden", "true"):wikitext("i")
titleRow:node(notesBtn)
end


-- Hero Bar Slot 2: Reserved container (EXCEPTION)
if hasReq then
function PLUGINS.ReservedInfo(rec, ctx)
local pillRow = wrap:tag("div")
local wrap = mw.html.create("div")
pillRow:addClass("sv-pill-row")
wrap:addClass("sv-herobar-2-wrap")
pillRow:addClass("sv-pill-row--req")
return {
local pill = pillRow:tag("span")
inner = tostring(wrap),
pill:addClass("sv-pill sv-pill--req sv-tip-btn")
classes = "module-herobar-2",
pill:attr("role", "button")
}
pill:attr("tabindex", "0")
end
pill:attr("data-sv-tip", "req")
pill:attr("aria-label", "Requirements")
pill:attr("aria-expanded", "false")
pill:wikitext("Requirements")
end


-- Module Slot 1: Level Selector (EXCEPTION)
if hasNotes then
function PLUGINS.LevelSelector(rec, ctx)
local notesContent = wrap:tag("div")
local level = ctx.level or 1
notesContent:addClass("sv-tip-content")
local maxLevel = ctx.maxLevel or 1
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


local inner = mw.html.create("div")
if hasReq then
inner:addClass("sv-level-ui")
local reqContent = wrap:tag("div")
reqContent:addClass("sv-tip-content")
reqContent:attr("data-sv-tip-content", "req")


inner:tag("div")
if #reqSkills > 0 then
:addClass("sv-level-label")
local section = reqContent:tag("div")
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
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 slider = inner:tag("div"):addClass("sv-level-slider")
if #reqWeapons > 0 then
local section = reqContent:tag("div")
section:addClass("sv-tip-section")
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Weapons")
section:tag("div"):wikitext(table.concat(reqWeapons, ", "))
end


if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
if #reqStances > 0 then
slider:tag("input")
local section = reqContent:tag("div")
:attr("type", "range")
section:addClass("sv-tip-section")
:attr("min", "1")
section:tag("span"):addClass("sv-tip-label"):wikitext("Required Stances")
:attr("max", tostring(maxLevel))
section:tag("div"):wikitext(table.concat(reqStances, ", "))
: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
end


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


-- Module: Skill Type (PORTABLE)
-- 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
--  - MobileDamage, Element, Target, Cast, Hits, Combo (CSS reorder)
function PLUGINS.SkillType(rec, ctx)
function PLUGINS.SkillType(rec, ctx)
local typeBlock = (type(rec.Type) == "table") and rec.Type or {}
local typeBlock = (type(rec.Type) == "table") and rec.Type or {}
local hideDamageAndElement = (ctx.nonDamaging == true)
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)
local function valName(x)
if x == nil then return nil end
if x == nil then return nil end
Line 1,256: Line 1,137:
if x.ID and x.ID ~= "" then return tostring(x.ID) end
if x.ID and x.ID ~= "" then return tostring(x.ID) end
if x.Value ~= nil then return tostring(x.Value) end
if x.Value ~= nil then return tostring(x.Value) end
end
if type(x) == "number" then
return tostring(x)
end
end
if type(x) == "string" and x ~= "" then
if type(x) == "string" and x ~= "" then
Line 1,261: Line 1,145:
end
end
return nil
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
end


local grid = mw.html.create("div")
local grid = mw.html.create("div")
grid:addClass("sv-type-grid")
grid:addClass("sv-type-grid")
grid:addClass("sv-compact-root")


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


local chunk = grid:tag("div"):addClass("sv-type-chunk")
local chunk = grid:tag("div")
chunk:tag("div"):addClass("sv-type-label"):wikitext(mw.text.nowiki(label))
:addClass("sv-type-chunk")
chunk:tag("div"):addClass("sv-type-value"):wikitext(mw.text.nowiki(v))
: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
end


if not hideDamageAndElement then
-- Damage + Element + Hits bundle (hidden when non-damaging)
addChunk("Damage",  typeBlock.Damage or typeBlock["Damage Type"])
if not hideDamageBundle then
addChunk("Element", typeBlock.Element or typeBlock["Element Type"])
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
end


addChunk("Target", typeBlock.Target or typeBlock["Target Type"])
-- Target + Cast
addChunk("Cast",  typeBlock.Cast  or typeBlock["Cast Type"])
local tgt = valName(typeBlock.Target or typeBlock["Target Type"])
local cst = valName(typeBlock.Cast  or typeBlock["Cast Type"])


return {
if tgt and not isNoneLike(tgt) then
inner = added and tostring(grid) or "",
addChunk("target", "Target", mw.text.nowiki(tgt))
classes = "module-skill-type",
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
 
-- 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: SourceType (Modifier + Source + Scaling)  (PORTABLE)
 
-- 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,300: Line 1,329:
local sourceVal  = nil
local sourceVal  = nil
local scaling    = nil
local scaling    = nil
-- sourceValueForLevel: dynamic formatting for structured Source blocks.
local function sourceValueForLevel(src)
if type(src) ~= "table" then
return nil
end
local per = src["Per Level"]
if type(per) == "table" and #per > 0 then
if isFlatList(per) then
local one  = formatUnitValue(per[1]) or tostring(per[1])
local show = formatUnitValue(src.Base) or one
return show and mw.text.nowiki(show) or nil
end
local series = {}
for _, v in ipairs(per) do
table.insert(series, formatUnitValue(v) or tostring(v))
end
return dynSpan(series, level)
end
return valuePairDynamicValueOnly(src, maxLevel, level)
end


if type(rec.Source) == "table" then
if type(rec.Source) == "table" then
Line 1,308: Line 1,361:


sourceKind = src.Type or ((src.Healing == true) and "Healing") or "Damage"
sourceKind = src.Type or ((src.Healing == true) and "Healing") or "Damage"
sourceVal  = sourceValueForLevel(src, maxLevel, level)
sourceVal  = sourceValueForLevel(src)
scaling    = src.Scaling
scaling    = src.Scaling
end
end
Line 1,368: Line 1,421:
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")
local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")


local extra = { "module-source-type" }
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,379: Line 1,432:
local wrap = mw.html.create("div")
local wrap = mw.html.create("div")
wrap:addClass("sv-source-grid")
wrap:addClass("sv-source-grid")
wrap:addClass("sv-compact-root")


if hasMod then
if hasMod then
Line 1,408: Line 1,462:
end
end


-- Module: Quick Stats (PORTABLE)
-- 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,437: Line 1,492:


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


-- Timings
-- Timings
local castSeries = seriesFromValuePair(bt["Cast Time"], maxLevel)
local castVal = displayFromSeries(seriesFromValuePair(bt["Cast Time"], maxLevel), level)
local cdSeries   = seriesFromValuePair(bt["Cooldown"],  maxLevel)
local cdVal   = displayFromSeries(seriesFromValuePair(bt["Cooldown"],  maxLevel), level)
local durSeries = seriesFromValuePair(bt["Duration"],  maxLevel)
local durVal = displayFromSeries(seriesFromValuePair(bt["Duration"],  maxLevel), level)
 
local castVal = displayFromSeries(castSeries, level)
local cdVal  = displayFromSeries(cdSeries, level)
local durVal  = displayFromSeries(durSeries, level)


-- Promote status duration if 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
durSeries = seriesFromValuePair(promo.durationBlock, maxLevel)
durVal = displayFromSeries(seriesFromValuePair(promo.durationBlock, maxLevel), level)
durVal = displayFromSeries(durSeries, level)
end
end


-- Cost: combine MP + HP; never show "0 HP" (seriesFromValuePair already turns 0 into "—")
-- 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,493: Line 1,543:
local grid = mw.html.create("div")
local grid = mw.html.create("div")
grid:addClass("sv-m4-grid")
grid:addClass("sv-m4-grid")
grid:addClass("sv-compact-root")


local function addCell(label, val)
local function addCell(label, val)
Line 1,510: Line 1,561:
inner = tostring(grid),
inner = tostring(grid),
classes = "module-quick-stats",
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
-- Column 2: Special Mechanics (stacked)
if hasMech then
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
return {
inner = tostring(root),
classes = "module-special-mechanics",
}
end
-- PLUGIN: LevelSelector (Hero Module Slot 4) - JS level slider.
function PLUGINS.LevelSelector(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
local inner = mw.html.create("div")
inner:addClass("sv-level-ui")
inner:tag("div")
:addClass("sv-level-label")
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
local slider = inner:tag("div"):addClass("sv-level-slider")
if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
slider:tag("input")
:attr("type", "range")
:attr("min", "1")
:attr("max", tostring(maxLevel))
:attr("value", tostring(level))
:addClass("sv-level-range")
:attr("aria-label", "Skill level select")
else
inner:addClass("sv-level-ui-single")
slider:addClass("sv-level-slider-single")
end
return {
inner = tostring(inner),
classes = "module-level-selector",
}
}
end
end
Line 1,517: Line 1,773:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- normalizeResult: normalize plugin return values into {inner, classes}.
local function normalizeResult(res)
local function normalizeResult(res)
if res == nil then return nil end
if res == nil then return nil end
Line 1,532: Line 1,789:
end
end


-- safeCallPlugin: pcall wrapper to prevent infobox failure on plugin errors.
local function safeCallPlugin(name, rec, ctx)
local function safeCallPlugin(name, rec, ctx)
local fn = PLUGINS[name]
        local fn = PLUGINS[name]
if type(fn) ~= "function" then
        if type(fn) ~= "function" then
return nil
                return nil
end
        end
local ok, out = pcall(fn, rec, ctx)
        local ok, out = pcall(fn, rec, ctx)
if not ok then
        if not ok then
return nil
                return nil
end
        end
return normalizeResult(out)
        return normalizeResult(out)
end
end


local function renderHeroBarSlot(slotIndex, rec, ctx)
-- isEmptySlotContent: true when a slot has no meaningful content.
local pluginName = HERO_BAR_SLOT_ASSIGNMENT[slotIndex]
-- NOTE: JS placeholders (sv-dyn spans, slider markup) are considered content.
if not pluginName then
local function isEmptySlotContent(inner)
return heroBarBox(slotIndex, nil, nil, "", true)
        if inner == nil then return true end
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 res = safeCallPlugin(pluginName, rec, ctx)
        local trimmed = mw.text.trim(raw)
if not res or not res.inner or res.inner == "" then
        if trimmed == "" or trimmed == "" then
return heroBarBox(slotIndex, pluginName, nil, "", true)
                return true
end
        end


return heroBarBox(slotIndex, pluginName, res.classes, res.inner, false)
        local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", ""))
        return (withoutTags == "" or withoutTags == "—")
end
end


local function renderModuleSlot(slotIndex, rec, ctx)
-- renderHeroSlot: render a standardized hero slot by plugin assignment.
local pluginName = HERO_MODULE_SLOT_ASSIGNMENT[slotIndex]
local function renderHeroSlot(slotIndex, rec, ctx)
if not pluginName then
        local pluginName = HERO_SLOT_ASSIGNMENT[slotIndex]
return moduleBox(slotIndex, nil, nil, "", true)
        if not pluginName then
end
                return nil
        end


local res = safeCallPlugin(pluginName, rec, ctx)
        local res = safeCallPlugin(pluginName, rec, ctx)
if not res or not res.inner or res.inner == "" then
        if not res or isEmptySlotContent(res.inner) then
return moduleBox(slotIndex, pluginName, nil, "", true)
                return nil
end
        end


return moduleBox(slotIndex, pluginName, res.classes, res.inner, false)
        return {
                inner = res.inner,
                classes = res.classes,
        }
end
end


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


local function buildHeroBarUI(rec, ctx)
-- buildHeroSlotsUI: build the standardized 4-row slot grid (2 columns).
local bar = mw.html.create("div")
local function buildHeroSlotsUI(rec, ctx)
bar:addClass("hero-bar-grid")
        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


bar:wikitext(renderHeroBarSlot(1, rec, ctx))
        local hasSlots = false
bar:wikitext(renderHeroBarSlot(2, rec, ctx))
        for _, pair in ipairs({ { 1, 2 }, { 3, 4 }, { 5, 6 }, { 7, 8 } }) do
                local left  = slots[pair[1]]
                local right = slots[pair[2]]


return tostring(bar)
                if left or right then
end
                        hasSlots = true


local function buildHeroModulesUI(rec, ctx)
                        if left and right then
local grid = mw.html.create("div")
                                grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isEmpty = false }))
grid:addClass("hero-modules-grid")
                                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


for slot = 1, 4 do
        if not hasSlots then
grid:wikitext(renderModuleSlot(slot, rec, ctx))
                return ""
end
        end


return tostring(grid)
        return tostring(grid)
end
end


local function addHeroModulesRow(tbl, modulesUI)
-- addHeroSlotsRow: add the standardized slot grid into the infobox table.
if not modulesUI or modulesUI == "" then
local function addHeroSlotsRow(tbl, slotsUI)
return
        if not slotsUI or slotsUI == "" then
end
                return
        end


local row = tbl:tag("tr")
        local row = tbl:tag("tr")
row:addClass("hero-modules-row")
        row:addClass("sv-slot-row")


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


Line 1,615: Line 1,902:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- buildInfobox: render a single skill infobox.
local function buildInfobox(rec, opts)
local function buildInfobox(rec, opts)
opts = opts or {}
opts = opts or {}
Line 1,623: Line 1,911:
local level = clamp(maxLevel, 1, maxLevel)
local level = clamp(maxLevel, 1, maxLevel)


-- ctx passed to every plug-in (the only allowed “shared state”)
local ctx = {
local ctx = {
maxLevel = maxLevel,
maxLevel = maxLevel,
level = level,
level = level,
-- computed below:
nonDamaging = false,
nonDamaging = false,
promo = nil,
promo = nil,
}
}


-- Determine “non-damaging” for SkillType (hide Damage/Element)
-- Non-damaging hides Damage/Element/Hits in SkillType
do
do
local dmgVal = nil
local dmgVal = nil
Line 1,644: Line 1,930:
end
end


-- Duration promotion (for QuickStats + Applies suppression)
ctx.promo = computeDurationPromotion(rec, maxLevel)
ctx.promo = computeDurationPromotion(rec, maxLevel)


Line 1,662: 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 (special-cased for now)
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
local modulesUI = buildHeroModulesUI(rec, ctx)
addHeroModulesRow(root, modulesUI)


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


-- Requirements
        -- Mechanics (keep small extras only)
local req = rec.Requirements or {}
        local mech = rec.Mechanics or {}
local hasReq =
        if next(mech) ~= nil then
(type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0) or
                if mech["Autocast Multiplier"] ~= nil then
(type(req["Required Weapons"]) == "table" and #req["Required Weapons"] > 0) or
                        addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
(type(req["Required Stances"]) == "table" and #req["Required Stances"] > 0)
                end
 
        end
if hasReq then
if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
local skillParts = {}
for _, rs in ipairs(req["Required Skills"]) do
local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or "Unknown"
local lvlReq  = rs["Required Level"]
if lvlReq then
table.insert(skillParts, string.format("%s (Lv.%s)", nameReq, lvlReq))
else
table.insert(skillParts, nameReq)
end
end
addRow(root, "Required Skills", table.concat(skillParts, ", "), "sv-row-req", "Requirements.Required Skills")
end
 
addRow(root, "Required Weapons", listToText(req["Required Weapons"]), "sv-row-req", "Requirements.Required Weapons")
addRow(root, "Required Stances", listToText(req["Required Stances"]), "sv-row-req", "Requirements.Required Stances")
end
 
-- Mechanics (detailed rows still shown; Range/Area/Timings/Cost are in QuickStats)
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
 
addRow(root, "Combo",            formatCombo(mech.Combo), "sv-row-mech", "Mechanics.Combo")
addRow(root, "Special Mechanics", formatMechanicEffects(mech.Effects, maxLevel, level), "sv-row-mech", "Mechanics.Effects")
end


-- Source + Scaling are displayed in SourceType module now, so we do NOT repeat them as body rows.
-- Legacy damage breakdown (only when Source absent)
-- Keep the detailed legacy damage breakdown rows only (Main/Flat/Reflect/Healing) when Source missing.
if type(rec.Source) ~= "table" then
if type(rec.Source) ~= "table" then
local dmg = rec.Damage or {}
local dmg = rec.Damage or {}
Line 1,761: Line 1,994:
end
end
end
end
local flatList = dmg["Flat Damage"]
local reflList = dmg["Reflect Damage"]


addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
addRow(root, "Flat Damage",    formatDamageList(flatList, maxLevel, level, false), "sv-row-source", "Damage.Flat Damage")
addRow(root, "Flat Damage",    formatDamageList(dmg["Flat Damage"], maxLevel, level, false),       "sv-row-source", "Damage.Flat Damage")
addRow(root, "Reflect Damage", formatDamageList(reflList, maxLevel, level, false), "sv-row-source", "Damage.Reflect Damage")
addRow(root, "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")
addRow(root, "Healing",        formatDamageList(healOnly, maxLevel, level, false),                 "sv-row-source", "Damage.Healing")
end
end
end
end


-- Modifiers flags
-- Status rows
local modsText = formatModifiers(rec.Modifiers)
local function formatStatusApplications(list, suppressDurationIndex)
if modsText then
if type(list) ~= "table" or #list == 0 then return nil end
addRow(root, "Flags", modsText, "sv-row-meta", "Modifiers")
 
local parts = {}
for idx, s in ipairs(list) do
if type(s) == "table" then
local typ  = s.Type or s.Scope or "Target"
local name = s["Status External Name"] or s["Status Internal Name"] or "Unknown status"
local seg = tostring(typ) .. " – " .. tostring(name)
local detail = {}
 
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(s.Chance) == "table" then
local t = valuePairDynamicValueOnly(s.Chance, maxLevel, level)
if t then table.insert(detail, "Chance: " .. t) end
end
 
if #detail > 0 then
seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
end
 
table.insert(parts, seg)
end
end
 
return (#parts > 0) and table.concat(parts, "<br />") or nil
end
 
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
end


-- Status (suppress promoted duration line if needed)
local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
local statusApps = formatStatusApplications(rec["Status Applications"], maxLevel, level, suppressIdx)
local statusApps = formatStatusApplications(rec["Status Applications"], suppressIdx)
local statusRem  = formatStatusRemoval(rec["Status Removal"], maxLevel, level)
local statusRem  = formatStatusRemoval(rec["Status Removal"])
if statusApps or statusRem then
if statusApps or statusRem then
addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications")
addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications")
Line 1,787: Line 2,074:
end
end


-- Events
        -- 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
                return (#parts > 0) and table.concat(parts, "<br />") or nil
        end
 
local eventsText = formatEvents(rec.Events)
local eventsText = formatEvents(rec.Events)
if eventsText then
if eventsText then
Line 1,793: 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,830: 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