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
 
(24 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:GameSkills
-- Module:GameSkills
--
--
-- Phase 6.5+ (Plug-in Slot Architecture + Context-Aware Rendering)
-- Phase 6.5+ (Plug-in Slot Architecture)
--
--
-- Standard Hero 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)
--
--
-- NEW: Plug-ins receive ctx:
-- Requires Common.js:
--  ctx.region = "herobar" | "modules"
--  - updates .sv-dyn spans via data-series
--  ctx.slot  = slot index within that region
--  - updates .sv-level-num + data-level on .sv-skill-card
--  ctx.level / ctx.maxLevel / ctx.nonDamaging / ctx.promo
--  - binds to input.sv-level-range inside each card


local GameData = require("Module:GameData")
local GameData = require("Module:GameData")
Line 22: 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 34: 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 39: 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 47: 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 60: 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 69: 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 84: 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 99: Line 207:
end
end


local function ctxClone(base, extra)
-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row.
local out = {}
if type(base) == "table" then
for k, v in pairs(base) do out[k] = v end
end
if type(extra) == "table" then
for k, v in pairs(extra) do out[k] = v end
end
return out
end
 
-- Add a labeled infobox 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 118: 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 129: Line 222:
end
end


-- Handles either a scalar OR { Value = ..., Unit = ... }
-- 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 157: 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 172: 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 185: 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 205: 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 211: 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
Line 217: Line 309:
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
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).
local function valuePairDynamicLines(name, block, maxLevel, level)
if type(block) ~= "table" then
return {}
end
 
local base = block.Base
local per  = block["Per Level"]
 
if type(per) == "table" then
if #per == 0 then
local baseText = formatUnitValue(base)
return baseText and { 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
 
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 304: Line 348:
end
end


-- 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 339: 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 373: Line 416:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Formatting helpers
-- 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 395: 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 446: 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 462: 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 = {}
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 679: 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 710: 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 734: Line 572:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Slot config (edit these only to rearrange layout)
-- Slot config (edit these tables only to rearrange layout)
----------------------------------------------------------------------
----------------------------------------------------------------------


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


Line 753: Line 590:
----------------------------------------------------------------------
----------------------------------------------------------------------


local function heroBarBox(slot, pluginName, extraClasses, innerHtml, isEmpty)
-- slotBox: standardized wrapper for all hero card slots.
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))
box:attr("data-region", "herobar")
box:attr("data-slot", tostring(slot))
if pluginName then
box:attr("data-plugin", tostring(pluginName))
end


if extraClasses then
if opts.isFull then
if type(extraClasses) == "string" then
box:addClass("sv-slot--full")
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")
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")
box:addClass("hero-module")
box:addClass("hero-module-" .. tostring(slot))
box:attr("data-hero-module", tostring(slot))
box:attr("data-region", "modules")
box:attr("data-slot", tostring(slot))
if pluginName then
box:attr("data-plugin", tostring(pluginName))
end
end


Line 801: 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")
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 820: Line 624:


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Shared helpers used by plug-ins
-- 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 855: 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 866: 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 914: 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 937: Line 722:
local series = {}
local series = {}


-- 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 949: Line 735:
end
end


-- Empty per-list -> base only
if type(per) == "table" and #per == 0 then
if type(per) == "table" and #per == 0 then
local one = fmtAny(base)
local one = fmtAny(base)
Line 960: Line 747:
end
end


-- Scalar per -> compute base + per*level (fallback)
local baseN = toNum(base) or 0
local baseN = toNum(base) or 0
local perN  = toNum(per)
local perN  = toNum(per)
Line 976: Line 764:
end
end


-- Base-only scalar
local raw = (base ~= nil) and base or per
local raw = (base ~= nil) and base or per
local one = fmtAny(raw)
local one = fmtAny(raw)
Line 990: 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,012: 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
return nil
end
end


local name, num
-- 1) Read Area Size label/name
local rawSize = area["Area Size"]
if rawSize == nil then
return nil
end
 
local sizeName = nil
if type(rawSize) == "table" then
sizeName = rawSize.Name or rawSize.ID or rawSize.Value
elseif type(rawSize) == "string" then
sizeName = rawSize
elseif type(rawSize) == "number" then
-- uncommon; treat as numeric-only label
sizeName = tostring(rawSize)
end


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,084: 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)
if type(rec) ~= "table" then return nil end
if type(rec) ~= "table" then return nil end
Line 1,130: Line 970:
local PLUGINS = {}
local PLUGINS = {}


-- 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)


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


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


function PLUGINS.LevelSelector(rec, ctx)
if hasNotes then
local level = ctx.level or 1
local notesContent = wrap:tag("div")
local maxLevel = ctx.maxLevel or 1
notesContent:addClass("sv-tip-content")
notesContent:attr("data-sv-tip-content", "notes")
notesContent:tag("div"):addClass("sv-tip-title"):wikitext("Notes")
notesContent:tag("div"):wikitext(table.concat(notesList, "<br />"))
end


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


-- 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)
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,204: 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,209: 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


-- 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,247: 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,255: 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


-- Fallback to legacy Damage lists if Source absent
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
local dmg = rec.Damage
local dmg = rec.Damage
Line 1,314: 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,325: 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,354: Line 1,462:
end
end


-- QuickStats:
-- PLUGIN: QuickStats (Hero Module Slot 2) - 3x2 grid (range/area/cost/cast/cd/duration).
-- - In modules row: classic 3x2 grid (.sv-m4-grid)
-- NOTE: Hits does NOT live here (it lives in SkillType).
-- - In hero bar: compact strip wrapper (.sv-qs-strip) so CSS can "flatten" on mobile
function PLUGINS.QuickStats(rec, ctx)
function PLUGINS.QuickStats(rec, ctx)
local level = ctx.level or 1
local level = ctx.level or 1
Line 1,368: Line 1,475:
local function dash() return "—" end
local function dash() return "—" end


-- 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,383: Line 1,491:
end
end


local areaVal = formatAreaSize(mech.Area)
-- Area
 
local areaVal = formatAreaSize(mech.Area, maxLevel, level)
local castSeries = seriesFromValuePair(bt["Cast Time"], maxLevel)
local cdSeries  = seriesFromValuePair(bt["Cooldown"], maxLevel)
local durSeries  = seriesFromValuePair(bt["Duration"], maxLevel)


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


-- Promote status duration if needed
if (durVal == nil) and type(promo) == "table" and type(promo.durationBlock) == "table" then
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: MP + HP
local function labeledSeries(block, label)
local function labeledSeries(block, label)
local s = seriesFromValuePair(block, maxLevel)
local s = seriesFromValuePair(block, maxLevel)
Line 1,434: Line 1,541:
local costVal = displayFromSeries(costSeries, level)
local costVal = displayFromSeries(costSeries, level)


local inHeroBar = (type(ctx) == "table" and ctx.region == "herobar")
local grid = mw.html.create("div")
 
grid:addClass("sv-m4-grid")
local wrap = mw.html.create("div")
grid:addClass("sv-compact-root")
wrap:addClass(inHeroBar and "sv-qs-strip" or "sv-m4-grid")


local function addCell(label, val)
local function addCell(label, val)
local cell = wrap:tag("div"):addClass("sv-m4-cell")
local cell = grid:tag("div"):addClass("sv-m4-cell")
cell:tag("div"):addClass("sv-m4-label"):wikitext(mw.text.nowiki(label))
cell:tag("div"):addClass("sv-m4-label"):wikitext(mw.text.nowiki(label))
cell:tag("div"):addClass("sv-m4-value"):wikitext(val or dash())
cell:tag("div"):addClass("sv-m4-value"):wikitext(val or dash())
Line 1,448: Line 1,554:
addCell("Area",      areaVal)
addCell("Area",      areaVal)
addCell("Cost",      costVal)
addCell("Cost",      costVal)
addCell("Cast",     castVal)
addCell("Cast Time", castVal)
addCell("CD",       cdVal)
addCell("Cooldown", cdVal)
addCell("Dur",       durVal)
addCell("Duration", durVal)


local classes = { "module-quick-stats" }
return {
if inHeroBar then
inner = tostring(grid),
table.insert(classes, "sv-qs-in-herobar")
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
end


return {
return {
inner = tostring(wrap),
inner = tostring(root),
classes = classes,
classes = "module-special-mechanics",
}
}
end
end


-- Alias plug-in name you can assign in slots:
-- PLUGIN: LevelSelector (Hero Module Slot 4) - JS level slider.
-- HERO_BAR_SLOT_ASSIGNMENT[2] = "Mechanics"
function PLUGINS.LevelSelector(rec, ctx)
function PLUGINS.Mechanics(rec, ctx)
local level = ctx.level or 1
local res = PLUGINS.QuickStats(rec, ctx)
local maxLevel = ctx.maxLevel or 1
if type(res) == "table" then
 
local cls = res.classes
local inner = mw.html.create("div")
if type(cls) == "string" then
inner:addClass("sv-level-ui")
cls = { cls }
 
elseif type(cls) ~= "table" then
inner:tag("div")
cls = {}
:addClass("sv-level-label")
end
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
table.insert(cls, "module-mechanics")
 
res.classes = cls
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
end
return res
 
return {
inner = tostring(inner),
classes = "module-level-selector",
}
end
end


Line 1,484: 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,499: 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 ctxSlot = ctxClone(ctx, { region = "herobar", slot = slotIndex })
        local trimmed = mw.text.trim(raw)
local res = safeCallPlugin(pluginName, rec, ctxSlot)
        if trimmed == "" or trimmed == "" then
if not res or not res.inner or res.inner == "" then
                return true
return heroBarBox(slotIndex, pluginName, nil, "", 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 ctxSlot = ctxClone(ctx, { region = "modules", slot = slotIndex })
        local res = safeCallPlugin(pluginName, rec, ctx)
local res = safeCallPlugin(pluginName, rec, ctxSlot)
        if not res or isEmptySlotContent(res.inner) then
if not res or not res.inner or res.inner == "" then
                return nil
return moduleBox(slotIndex, pluginName, nil, "", true)
        end
end


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


Line 1,545: Line 1,847:
----------------------------------------------------------------------
----------------------------------------------------------------------


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")


bar:wikitext(renderHeroBarSlot(1, rec, ctx))
        local slots = {}
bar:wikitext(renderHeroBarSlot(2, rec, ctx))
        for slot = 1, 8 do
                slots[slot] = renderHeroSlot(slot, rec, ctx)
        end


return tostring(bar)
        local hasSlots = false
end
        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


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,584: 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,599: Line 1,918:
}
}


-- Non-damaging hides Damage/Element/Hits in SkillType
do
do
local dmgVal = nil
local dmgVal = nil
Line 1,627: Line 1,947:
end
end


local desc  = rec.Description or ""
-- Standardized slot grid
addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx))


local heroRow = root:tag("tr")
-- Users (hide on direct skill page)
heroRow:addClass("spiritvale-infobox-main")
        if showUsers then
heroRow:addClass("sv-hero-title-row")
                local users = rec.Users or {}
heroRow:addClass("hero-title-bar")
                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


local heroCell = heroRow:tag("th")
        -- Mechanics (keep small extras only)
heroCell:attr("colspan", 2)
        local mech = rec.Mechanics or {}
heroCell:addClass("sv-hero-title-cell")
        if next(mech) ~= nil then
heroCell:wikitext(buildHeroBarUI(rec, ctx))
                if mech["Autocast Multiplier"] ~= nil then
                        addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
                end
        end


if desc ~= "" then
-- Legacy damage breakdown (only when Source absent)
local descRow = root:tag("tr")
if type(rec.Source) ~= "table" then
descRow:addClass("spiritvale-infobox-main")
local dmg = rec.Damage or {}
descRow:addClass("sv-hero-desc-row")
if next(dmg) ~= nil then
descRow:addClass("hero-description-bar")
local main = dmg["Main Damage"]
local mainNonHeal, healOnly = {}, {}


local descCell = descRow:tag("td")
if type(main) == "table" then
descCell:attr("colspan", 2)
for _, d in ipairs(main) do
descCell:addClass("sv-hero-desc-cell")
if type(d) == "table" and d.Type == "Healing" then
table.insert(healOnly, d)
else
table.insert(mainNonHeal, d)
end
end
end


local descInner = descCell:tag("div")
addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
descInner:addClass("spiritvale-infobox-main-right-inner")
addRow(root, "Flat Damage",    formatDamageList(dmg["Flat Damage"], maxLevel, level, false),        "sv-row-source", "Damage.Flat Damage")
addRow(root, "Reflect Damage", formatDamageList(dmg["Reflect Damage"], maxLevel, level, false),    "sv-row-source", "Damage.Reflect Damage")
addRow(root, "Healing",        formatDamageList(healOnly, maxLevel, level, false),                  "sv-row-source", "Damage.Healing")
end
end


descInner:tag("div")
-- Status rows
:addClass("spiritvale-infobox-description")
local function formatStatusApplications(list, suppressDurationIndex)
:wikitext(string.format("''%s''", desc))
if type(list) ~= "table" or #list == 0 then return nil end
end


local modulesUI = buildHeroModulesUI(rec, ctx)
local parts = {}
addHeroModulesRow(root, modulesUI)
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 showUsers then
if idx ~= suppressDurationIndex and type(s.Duration) == "table" then
local users = rec.Users or {}
local t = valuePairDynamicValueOnly(s.Duration, maxLevel, level)
addRow(root, "Classes",  listToText(users.Classes)"sv-row-users", "Users.Classes")
if t then table.insert(detail, "Duration: " .. t) end
addRow(root, "Summons",  listToText(users.Summons), "sv-row-users", "Users.Summons")
end
addRow(root, "Monsters", listToText(users.Monsters), "sv-row-users", "Users.Monsters")
addRow(root, "Events",  listToText(users.Events),  "sv-row-users", "Users.Events")
end


local req = rec.Requirements or {}
if type(s.Chance) == "table" then
local hasReq =
local t = valuePairDynamicValueOnly(s.Chance, maxLevel, level)
(type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0) or
if t then table.insert(detail, "Chance: " .. t) end
(type(req["Required Weapons"]) == "table" and #req["Required Weapons"] > 0) or
end
(type(req["Required Stances"]) == "table" and #req["Required Stances"] > 0)


if hasReq then
if #detail > 0 then
if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
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
table.insert(parts, seg)
end
end
addRow(root, "Required Skills", table.concat(skillParts, ", "), "sv-row-req", "Requirements.Required Skills")
end
end


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


local mech = rec.Mechanics or {}
local function formatStatusRemoval(list)
if next(mech) ~= nil then
if type(list) ~= "table" or #list == 0 then return nil end
if mech["Autocast Multiplier"] ~= nil then
 
addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
local parts = {}
end
for _, r in ipairs(list) do
if type(r) == "table" then
local names = r["Status External Name"]
local label


addRow(root, "Combo",            formatCombo(mech.Combo), "sv-row-mech", "Mechanics.Combo")
if type(names) == "table" then
addRow(root, "Special Mechanics", formatMechanicEffects(mech.Effects, maxLevel, level), "sv-row-mech", "Mechanics.Effects")
label = table.concat(names, ", ")
end
elseif type(names) == "string" then
label = names
else
label = "Status"
end


if type(rec.Source) ~= "table" then
local amt = valuePairRawText(r)
local dmg = rec.Damage or {}
amt = amt and mw.text.nowiki(amt) or nil
if next(dmg) ~= nil then
local main = dmg["Main Damage"]
local mainNonHeal, healOnly = {}, {}


if type(main) == "table" then
local seg = mw.text.nowiki(label)
for _, d in ipairs(main) do
if amt then
if type(d) == "table" and d.Type == "Healing" then
seg = seg .. " " .. amt
table.insert(healOnly, d)
else
table.insert(mainNonHeal, d)
end
end
end
table.insert(parts, seg)
end
end
local flatList = dmg["Flat Damage"]
local reflList = dmg["Reflect Damage"]
addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
addRow(root, "Flat Damage",    formatDamageList(flatList, maxLevel, level, false), "sv-row-source", "Damage.Flat Damage")
addRow(root, "Reflect Damage", formatDamageList(reflList, maxLevel, level, false), "sv-row-source", "Damage.Reflect Damage")
addRow(root, "Healing",        formatDamageList(healOnly, maxLevel, level, false), "sv-row-source", "Damage.Healing")
end
end
end


local modsText = formatModifiers(rec.Modifiers)
return (#parts > 0) and table.concat(parts, "<br />") or nil
if modsText then
addRow(root, "Flags", modsText, "sv-row-meta", "Modifiers")
end
end


local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
local statusApps = formatStatusApplications(rec["Status Applications"], 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")
addRow(root, "Removes", statusRem,  "sv-row-status", "Status Removal")
addRow(root, "Removes", statusRem,  "sv-row-status", "Status Removal")
end
end
        -- Events
        local function formatEvents(list)
                if type(list) ~= "table" or #list == 0 then return nil end
                local parts = {}
                for _, ev in ipairs(list) do
                        if type(ev) == "table" then
                                local action = resolveDisplayName(ev.Action, "event") or ev.Action or "On event"
                                local name  = resolveSkillNameFromEvent(ev)
                                table.insert(parts, string.format("%s → %s", mw.text.nowiki(action), mw.text.nowiki(name)))
                        end
                end
                return (#parts > 0) and table.concat(parts, "<br />") or nil
        end


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


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


Line 1,783: 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