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

Join the Playtest on Steam Now: SpiritVale

Module:GameSkills: Difference between revisions

From SpiritVale Wiki
No edit summary
No edit summary
 
(7 intermediate revisions by the same user not shown)
Line 3: Line 3:
-- Phase 6.5+ (Plug-in Slot Architecture)
-- Phase 6.5+ (Plug-in Slot Architecture)
--
--
-- Standard Hero Layout:
-- Layout:
--  1) hero-title-bar        (TOP BAR, 2 slots: herobar 1..2)
--  1) hero-title-bar        (TOP BAR, 2 slots: herobar 1..2)
--  2) hero-description-bar  (description strip)
--  2) hero-description-bar  (description strip)
Line 23: Line 23:
local skillsCache
local skillsCache


-- getSkills: lazy-load + cache skill dataset from GameData.
local function getSkills()
local function getSkills()
if not skillsCache then
if not skillsCache then
Line 34: Line 35:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 41:
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 50:
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 64:
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 74:
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 90:
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
Line 91: Line 98:
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 107:
end
end


-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row.
local function addRow(tbl, label, value, rowClass, dataKey)
local function addRow(tbl, label, value, rowClass, dataKey)
if value == nil or value == "" then
if value == nil or value == "" then
Line 113: Line 122:
end
end


-- { Value, Unit } or scalar
-- 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 141: Line 150:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 156: Line 166:
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 169: Line 180:
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 return false end
if v == nil then return false end
Line 183: Line 195:
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 201: Line 214:
end
end


-- valuePairRawText: render Base/Per Level blocks into readable text (fallback).
local function valuePairRawText(block)
local function valuePairRawText(block)
if type(block) ~= "table" then
if type(block) ~= "table" then
Line 234: Line 248:
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 269: Line 284:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- getSkillById: locate a skill by internal ID.
local function getSkillById(id)
local function getSkillById(id)
id = trim(id)
id = trim(id)
Line 276: Line 292:
end
end


-- findSkillByName: locate a skill by external/display name.
local function findSkillByName(name)
local function findSkillByName(name)
name = trim(name)
name = trim(name)
Line 302: Line 319:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 321: Line 339:
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 372: Line 391:
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 398: Line 418:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 429: Line 450:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 455: Line 477:
local HERO_BAR_SLOT_ASSIGNMENT = {
local HERO_BAR_SLOT_ASSIGNMENT = {
[1] = "IconName",
[1] = "IconName",
[2] = "SkillType",
[2] = "SkillType", -- Damage/Element/Hits/Target/Cast/Combo strip
}
}


Line 462: Line 484:
[2] = "QuickStats",
[2] = "QuickStats",
[3] = "SpecialMechanics",
[3] = "SpecialMechanics",
[4] = "LevelSelector", -- NEW
[4] = "LevelSelector",
}
}


Line 469: Line 491:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- heroBarBox: wrapper for hero-bar slot modules.
local function heroBarBox(slot, extraClasses, innerHtml, isEmpty)
local function heroBarBox(slot, extraClasses, innerHtml, isEmpty)
local box = mw.html.create("div")
local box = mw.html.create("div")
Line 475: Line 498:
box:attr("data-hero-bar-module", tostring(slot))
box:attr("data-hero-bar-module", tostring(slot))


-- Mark herobar slot 2 as "compact-spread target" (CSS does the rest)
if slot == 2 then
if slot == 2 then
box:addClass("sv-herobar-compact")
box:addClass("sv-herobar-compact")
Line 500: Line 522:
end
end


-- moduleBox: wrapper for hero-module (2x2 grid) slot modules.
local function moduleBox(slot, extraClasses, innerHtml, isEmpty)
local function moduleBox(slot, extraClasses, innerHtml, isEmpty)
local box = mw.html.create("div")
local box = mw.html.create("div")
Line 530: Line 553:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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 562: Line 586:
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 573: Line 598:
end
end


-- legacyPercentAtLevel: compute “Base% + PerLevel%*level” for legacy entries.
local function legacyPercentAtLevel(entry, level)
local function legacyPercentAtLevel(entry, level)
if type(entry) ~= "table" then
if type(entry) ~= "table" then
Line 598: Line 624:
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 678: Line 705:
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 700: Line 728:
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
 
sizeName = sizeName and mw.text.trim(tostring(sizeName)) or nil
if not sizeName or sizeName == "" or isNoneLike(sizeName) then
return nil
end


if type(raw) == "table" then
-- 2) Find the numeric “exact number” to append
name = raw.Name or raw.ID or raw.Value
-- Prefer the explicit Area Distance block, then fall back to other known numeric keys.
num = raw.Value
local num = nil
if raw.Name or raw.ID then
 
name = raw.Name or raw.ID
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 772: Line 849:
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 818: Line 896:
local PLUGINS = {}
local PLUGINS = {}


-- Hero Bar Slot 1: Icon + Name
-- PLUGIN: IconName (Hero Bar Slot 1) - icon + name.
function PLUGINS.IconName(rec, ctx)
function PLUGINS.IconName(rec, ctx)
local icon  = rec.Icon
local icon  = rec.Icon
Line 842: Line 920:
end
end


-- Reserved placeholder (kept for future)
-- PLUGIN: SkillType (Hero Bar Slot 2) - 2 rows x 3 cells (desktop + mobile).
function PLUGINS.ReservedInfo(rec, ctx)
-- Rules:
local wrap = mw.html.create("div")
--  - If skill is non-damaging, hide Damage/Element/Hits.
wrap:addClass("sv-herobar-2-wrap")
--  - If Hits is empty, hide Hits.
return {
--  - If Combo is empty, hide Combo.
inner = tostring(wrap),
-- Ordering:
classes = "module-herobar-2",
--  - Desktop: Damage, Element, Hits, Target, Cast, Combo
}
--  - Mobile:  Damage, Element, Target, Cast, Hits, Combo (CSS reorder)
end
function PLUGINS.SkillType(rec, ctx)
local typeBlock = (type(rec.Type) == "table") and rec.Type or {}
local mech      = (type(rec.Mechanics) == "table") and rec.Mechanics or {}


-- Module: Level Selector
local level   = ctx.level or 1
function PLUGINS.LevelSelector(rec, ctx)
local level = ctx.level or 1
local maxLevel = ctx.maxLevel or 1
local maxLevel = ctx.maxLevel or 1


local inner = mw.html.create("div")
local hideDamageBundle = (ctx.nonDamaging == true)
inner:addClass("sv-level-ui")
 
-- valName: extract a display string from typical {Name/ID/Value} objects.
-- NOTE: Includes number support so Hits=2 (number) doesn't get dropped.
local function valName(x)
if x == nil then return nil end
if type(x) == "table" then
if x.Name and x.Name ~= "" then return tostring(x.Name) end
if x.ID and x.ID ~= "" then return tostring(x.ID) end
if x.Value ~= nil then return tostring(x.Value) end
end
if type(x) == "number" then
return tostring(x)
end
if type(x) == "string" and x ~= "" then
return x
end
return nil
end


inner:tag("div")
-- hitsDisplay: find + render Hits from multiple possible structured locations.
:addClass("sv-level-label")
local function hitsDisplay()
:wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))
local effects = (type(mech.Effects) == "table") and mech.Effects or {}


local slider = inner:tag("div"):addClass("sv-level-slider")
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 tonumber(maxLevel) and tonumber(maxLevel) > 1 then
if h == nil or isNoneLike(h) then
slider:tag("input")
return nil
:attr("type", "range")
end
: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 {
-- ValuePair-style table (Base/Per Level) => dynamic series
inner = tostring(inner),
if type(h) == "table" then
classes = "module-level-selector",
if h.Base ~= nil or h["Per Level"] ~= nil or type(h["Per Level"]) == "table" then
}
return displayFromSeries(seriesFromValuePair(h, maxLevel), level)
end
end


-- Module: Skill Type
-- Unit block {Value, Unit}
function PLUGINS.SkillType(rec, ctx)
if h.Value ~= nil then
local typeBlock = (type(rec.Type) == "table") and rec.Type or {}
local t = formatUnitValue(h)
local hideDamageAndElement = (ctx.nonDamaging == true)
return t and mw.text.nowiki(t) or nil
end


local function valName(x)
-- Fallback name extraction
if x == nil then return nil end
local function valName(x)
if type(x) == "table" then
if x == nil then return nil end
if x.Name and x.Name ~= "" then return tostring(x.Name) end
if type(x) == "table" then
if x.ID and x.ID ~= "" then return tostring(x.ID) end
if x.Name and x.Name ~= "" then return tostring(x.Name) end
if x.Value ~= nil then return tostring(x.Value) 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
end
if type(x) == "string" and x ~= "" then
if type(h) == "string" then
return x
local t = trim(h)
return (t and not isNoneLike(t)) and mw.text.nowiki(t) or nil
end
end
return nil
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


Line 908: Line 1,045:


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))
end
:attr("data-type-key", tostring(key))


if not hideDamageAndElement then
chunk:tag("div")
addChunk("Damage",  typeBlock.Damage  or typeBlock["Damage Type"])
:addClass("sv-type-label")
addChunk("Element", typeBlock.Element or typeBlock["Element Type"])
:wikitext(mw.text.nowiki(label))
 
chunk:tag("div")
:addClass("sv-type-value")
:wikitext(valueHtml)
end
end


addChunk("Target", typeBlock.Target or typeBlock["Target Type"])
-- Damage + Element + Hits bundle (hidden when non-damaging)
addChunk("Cast",  typeBlock.Cast  or typeBlock["Cast Type"])
if not hideDamageBundle then
local dmg  = valName(typeBlock.Damage or typeBlock["Damage Type"])
local ele  = valName(typeBlock.Element or typeBlock["Element Type"])
local hits = hitsDisplay()
 
if dmg and not isNoneLike(dmg) then
addChunk("damage", "Damage", mw.text.nowiki(dmg))
end
if ele and not isNoneLike(ele) then
addChunk("element", "Element", mw.text.nowiki(ele))
end
if hits then
addChunk("hits", "Hits", hits)
end
end
 
-- Target + Cast
local tgt = valName(typeBlock.Target or typeBlock["Target Type"])
local cst = valName(typeBlock.Cast  or typeBlock["Cast Type"])
 
if tgt and not isNoneLike(tgt) then
addChunk("target", "Target", mw.text.nowiki(tgt))
end
if cst and not isNoneLike(cst) then
addChunk("cast", "Cast", mw.text.nowiki(cst))
end
 
-- Combo
local combo = comboDisplay()
if combo then
addChunk("combo", "Combo", combo)
end


return {
return {
Line 932: Line 1,105:
end
end


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


-- sourceValueForLevel: dynamic formatting for structured Source blocks.
local function sourceValueForLevel(src)
local function sourceValueForLevel(src)
if type(src) ~= "table" then
if type(src) ~= "table" then
Line 1,073: Line 1,247:
end
end


-- Module: Quick Stats (3x2)
-- 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,102: Line 1,277:


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


-- Timings
-- Timings
Line 1,174: Line 1,349:
end
end


-- Module: Special Mechanics (Special Mechanics + Flags + Combo)
-- 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)
function PLUGINS.SpecialMechanics(rec, ctx)
local level = ctx.level or 1
local level = ctx.level or 1
Line 1,181: Line 1,360:
local mech    = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
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 effects = (type(mech.Effects) == "table") and mech.Effects or nil
local combo  = (type(mech.Combo) == "table") and mech.Combo or nil
local mods    = (type(rec.Modifiers) == "table") and rec.Modifiers 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


------------------------------------------------------------------
------------------------------------------------------------------
Line 1,188: Line 1,382:
------------------------------------------------------------------
------------------------------------------------------------------
local flagSet = {}
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)
local function addFlags(sub)
if type(sub) ~= "table" then return end
if type(sub) ~= "table" then return end
for k, v in pairs(sub) do
for k, v in pairs(sub) do
if v then
if v and allowFlag(k) then
flagSet[tostring(k)] = true
flagSet[tostring(k)] = true
end
end
Line 1,203: Line 1,421:
addFlags(mods["Special Modifiers"])
addFlags(mods["Special Modifiers"])
for k, v in pairs(mods) do
for k, v in pairs(mods) do
if type(v) == "boolean" and v then
if type(v) == "boolean" and v and allowFlag(k) then
flagSet[tostring(k)] = true
flagSet[tostring(k)] = true
end
end
Line 1,214: Line 1,432:


------------------------------------------------------------------
------------------------------------------------------------------
-- Special mechanics (label/value items)
-- Special mechanics (name => value)
------------------------------------------------------------------
------------------------------------------------------------------
local mechItems = {}
local mechItems = {}
Line 1,224: Line 1,442:


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


local value = nil
-- If Type exists and is distinct, prefix it.
if disp then
if t and not isNoneLike(t) and mw.ustring.lower(t) ~= mw.ustring.lower(tostring(name)) then
value = disp
if value then
end
value = mw.text.nowiki(t) .. ": " .. value
else
value = mw.text.nowiki(t)
end
end


-- If Type exists and is distinct, prefix it (keeps your previous behavior)
if value then
if t and not isNoneLike(t) and mw.ustring.lower(t) ~= mw.ustring.lower(tostring(name)) then
table.insert(mechItems, { label = tostring(name), value = value })
if value then
end
value = mw.text.nowiki(t) .. ": " .. value
else
value = mw.text.nowiki(t)
end
end
end
end
 
end
if value then
table.insert(mechItems, { label = tostring(name), value = value })
end
end
end
end
 
------------------------------------------------------------------
-- Combo (separate group)
------------------------------------------------------------------
local comboText = nil
if combo then
local typ = trim(combo.Type)
if typ and not isNoneLike(typ) then
local details = {}
 
local pct = formatUnitValue(combo.Percent)
if pct and not isZeroish(pct) then
table.insert(details, mw.text.nowiki(pct))
end
 
local dur = formatUnitValue(combo.Duration)
if dur and not isZeroish(dur) then
table.insert(details, mw.text.nowiki(dur))
end
 
if #details > 0 then
comboText = mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")"
else
comboText = mw.text.nowiki(typ)
end
end
end
end
end


------------------------------------------------------------------
-- Presence + empty fallback
------------------------------------------------------------------
local hasFlags = (#flags > 0)
local hasFlags = (#flags > 0)
local hasMech  = (#mechItems > 0)
local hasMech  = (#mechItems > 0)
local hasCombo = (comboText ~= nil and comboText ~= "")


if (not hasFlags) and (not hasMech) and (not hasCombo) then
if (not hasFlags) and (not hasMech) then
local root = mw.html.create("div")
local root = mw.html.create("div")
root:addClass("sv-sm-root")
root:addClass("sv-sm-root")
Line 1,299: Line 1,489:
if hasFlags then count = count + 1 end
if hasFlags then count = count + 1 end
if hasMech  then count = count + 1 end
if hasMech  then count = count + 1 end
if hasCombo then count = count + 1 end


------------------------------------------------------------------
-- Markup: 3 columns on desktop when all present; mobile CSS collapses
------------------------------------------------------------------
local root = mw.html.create("div")
local root = mw.html.create("div")
root:addClass("sv-sm-root")
root:addClass("sv-sm-root")
Line 1,310: Line 1,496:
local layout = root:tag("div"):addClass("sv-sm-layout")
local layout = root:tag("div"):addClass("sv-sm-layout")
layout:addClass("sv-sm-count-" .. tostring(count))
layout:addClass("sv-sm-count-" .. tostring(count))
if hasFlags then layout:addClass("sv-sm-has-flags") end
if hasMech  then layout:addClass("sv-sm-has-mech") end
if hasCombo then layout:addClass("sv-sm-has-combo") end


-- Column 1: Flags
-- Column 1: Flags
Line 1,330: Line 1,513:
one:tag("div"):addClass("sv-sm-value"):wikitext(it.value or "—")
one:tag("div"):addClass("sv-sm-value"):wikitext(it.value or "—")
end
end
end
-- Column 3: Combo
if hasCombo then
local ccol = layout:tag("div"):addClass("sv-sm-col"):addClass("sv-sm-col-combo")
local one = ccol:tag("div"):addClass("sv-sm-mech"):addClass("sv-sm-combo")
one:tag("div"):addClass("sv-sm-label"):wikitext("Combo")
one:tag("div"):addClass("sv-sm-value"):wikitext(comboText or "—")
end
end


Line 1,346: Line 1,521:
end
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


----------------------------------------------------------------------
----------------------------------------------------------------------
Line 1,351: Line 1,558:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- 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,366: Line 1,574:
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]
Line 1,378: Line 1,587:
end
end


-- renderHeroBarSlot: render a hero-bar slot by plugin assignment.
local function renderHeroBarSlot(slotIndex, rec, ctx)
local function renderHeroBarSlot(slotIndex, rec, ctx)
local pluginName = HERO_BAR_SLOT_ASSIGNMENT[slotIndex]
local pluginName = HERO_BAR_SLOT_ASSIGNMENT[slotIndex]
Line 1,392: Line 1,602:
end
end


-- renderModuleSlot: render a hero-module slot by plugin assignment.
local function renderModuleSlot(slotIndex, rec, ctx)
local function renderModuleSlot(slotIndex, rec, ctx)
local pluginName = HERO_MODULE_SLOT_ASSIGNMENT[slotIndex]
local pluginName = HERO_MODULE_SLOT_ASSIGNMENT[slotIndex]
Line 1,410: Line 1,621:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- buildHeroBarUI: build the top hero bar (2 slots).
local function buildHeroBarUI(rec, ctx)
local function buildHeroBarUI(rec, ctx)
local bar = mw.html.create("div")
local bar = mw.html.create("div")
Line 1,418: Line 1,630:
end
end


-- buildHeroModulesUI: build the 2x2 module grid row (4 slots).
local function buildHeroModulesUI(rec, ctx)
local function buildHeroModulesUI(rec, ctx)
local grid = mw.html.create("div")
local grid = mw.html.create("div")
Line 1,427: Line 1,640:
end
end


-- addHeroModulesRow: add the hero-modules row into the infobox table.
local function addHeroModulesRow(tbl, modulesUI)
local function addHeroModulesRow(tbl, modulesUI)
if not modulesUI or modulesUI == "" then
if not modulesUI or modulesUI == "" then
Line 1,445: Line 1,659:
----------------------------------------------------------------------
----------------------------------------------------------------------


-- buildInfobox: render a single skill infobox.
local function buildInfobox(rec, opts)
local function buildInfobox(rec, opts)
opts = opts or {}
opts = opts or {}
Line 1,460: Line 1,675:
}
}


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


-- Mechanics (keep small extras only; Special Mechanics/Flags/Combo are now in the module)
-- Mechanics (keep small extras only)
local mech = rec.Mechanics or {}
local mech = rec.Mechanics or {}
if next(mech) ~= nil then
if next(mech) ~= nil then
Line 1,591: Line 1,806:
end
end


-- Status rows (unchanged)
-- Status rows
local function formatStatusApplications(list, suppressDurationIndex)
local function formatStatusApplications(list, suppressDurationIndex)
if type(list) ~= "table" or #list == 0 then return nil end
if type(list) ~= "table" or #list == 0 then return nil end

Latest revision as of 19:23, 18 December 2025

Module:GameSkills

Module:GameSkills renders skill data from Data:skills.json into a reusable infobox-style table.

It is intended to be used via a template (for example Template:Skill) so that skills can be embedded on any page without creating individual skill pages.

This module:

  • Loads data via Module:GameDataGameData.loadSkills().
  • Looks up skills primarily by display "Name" (what editors use), with "Internal Name" as a fallback.
  • Builds a table with only the fields that actually exist for that skill.

Data source

Skill data comes from Data:skills.json, which is a JSON page with this top-level structure (see Module:GameData/doc for full details):

{
  "version": "SpiritVale-0.8.2",
  "schema_version": 1,
  "generated_at": "2025-12-12T17:24:05.807675+00:00",
  "records": [
    {
      "Name": "Some Skill",
      "Internal Name": "SomeSkillInternalId",
      "...": "other fields specific to skills"
    }
  ]
}

Each record is a single skill. Important keys:

  • "Name" – the display name (what players and editors will usually see and use).
  • "Internal Name" – the stable ID used internally and available as an optional parameter for power users and tooling.

Output

For a given skill, the module renders a table with the CSS class spiritvale-skill-infobox.

Depending on what exists in the JSON record, the table may include:

  • Header row with skill name (and icon, if present).
  • Icon (from "Icon", as a file name like skill-example.webp).
  • Description.
  • Type information:
    • Damage Type
    • Element Type
    • Target Type
    • Cast Type
  • Max Level.
  • Users:
    • Classes
    • Summons
    • Monsters
    • Events
  • Requirements:
    • Required Skills (with required level)
    • Required Weapons
    • Required Stances
  • Mechanics:
    • Range
    • Cast Time / Cooldown / Duration
    • Mana Cost
  • Damage and scaling:
    • Main Damage (base and per-level, where present)
    • Scaling (stat-based contributions)
  • Status interactions:
    • Status Applications (status name, scope, basic duration/chance info)

Rows are only shown if the underlying field exists in the JSON for that skill.


Public interface

The module exposes a single entry point for templates:

GameSkills.infobox(frame)

This is usually called via #invoke from a template, not directly from pages.

It accepts the following parameters (either passed directly or via a wrapper template):

  • 1 – unnamed parameter; treated as the skill "Name".
  • name – explicit display "Name" of the skill (equivalent to 1).
  • id"Internal Name" of the skill (optional fallback / power use).

Lookup order:

  1. If name or the first unnamed parameter is provided and matches a record’s "Name", that record is used.
  2. Otherwise, if id is provided and matches an "Internal Name", that record is used.
  3. If nothing is found, a small error message is returned and the page is categorized for tracking.

Example direct usage (not recommended; normally use a template):

No skills found for: GameSkills

or:

No skills found for: GameSkills

Template:Skill

The recommended way to use this module is via a small wrapper template, for example:

Template:Skill
 No skills found for: GameSkills

Typical usage on any page:

Bash
Damage
Melee
Element
Neutral
Target
Enemy
Cast
Target
Combo
Ready (4s)
Delivers a crushing blow with a chance to stun the target.
Modifier
Attack
Damage
325%
Scaling
2% Strength
Range
Area
Cost
12 MP
Cast Time
Cooldown
Duration
No Special Mechanics
Level 10 / 10
<input type="range" min="1" max="10" value="10" class="sv-level-range" aria-label="Skill level select" />
ClassesWarrior, Weaver
MonstersAshrend, Goblin King
Required SkillsAxe Mastery (Lv.1)
AppliesTarget – Stun (Duration: 3s, Chance: 30%)

or, explicitly:

Bash
Damage
Melee
Element
Neutral
Target
Enemy
Cast
Target
Combo
Ready (4s)
Delivers a crushing blow with a chance to stun the target.
Modifier
Attack
Damage
325%
Scaling
2% Strength
Range
Area
Cost
12 MP
Cast Time
Cooldown
Duration
No Special Mechanics
Level 10 / 10
<input type="range" min="1" max="10" value="10" class="sv-level-range" aria-label="Skill level select" />
ClassesWarrior, Weaver
MonstersAshrend, Goblin King
Required SkillsAxe Mastery (Lv.1)
AppliesTarget – Stun (Duration: 3s, Chance: 30%)

Internal IDs can still be used when needed:

Unknown Skill: Bash_InternalId

This keeps page wikitext simple while centralizing all JSON loading and formatting logic inside Lua.


-- Module:GameSkills
--
-- Phase 6.5+ (Plug-in Slot Architecture)
--
-- Layout:
--   1) hero-title-bar        (TOP BAR, 2 slots: herobar 1..2)
--   2) hero-description-bar  (description strip)
--   3) hero-modules row      (4 slots: hero-module-1..4)
--
-- Requires Common.js:
--   - updates .sv-dyn spans via data-series
--   - updates .sv-level-num + data-level on .sv-skill-card
--   - binds to input.sv-level-range inside each card

local GameData = require("Module:GameData")

local p = {}

----------------------------------------------------------------------
-- Data cache
----------------------------------------------------------------------

local skillsCache

-- getSkills: lazy-load + cache skill dataset from GameData.
local function getSkills()
	if not skillsCache then
		skillsCache = GameData.loadSkills()
	end
	return skillsCache
end

----------------------------------------------------------------------
-- Small utilities
----------------------------------------------------------------------

-- getArgs: read args from parent frame when invoked from a template.
local function getArgs(frame)
	local parent = frame:getParent()
	return (parent and parent.args) or frame.args
end

-- trim: normalize strings (nil if empty).
local function trim(s)
	if type(s) ~= "string" then
		return nil
	end
	s = mw.text.trim(s)
	return (s ~= "" and s) or nil
end

-- toNum: convert common scalar/table forms to a Lua number.
local function toNum(v)
	if type(v) == "number" then
		return v
	end
	if type(v) == "string" then
		return tonumber(v)
	end
	if type(v) == "table" and v.Value ~= nil then
		return toNum(v.Value)
	end
	return nil
end

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

-- fmtNum: consistent number formatting (trim trailing zeros).
local function fmtNum(n)
	if type(n) ~= "number" then
		return (n ~= nil) and tostring(n) or nil
	end

	if math.abs(n - math.floor(n)) < 1e-9 then
		return tostring(math.floor(n))
	end

	local s = string.format("%.4f", n)
	s = mw.ustring.gsub(s, "0+$", "")
	s = mw.ustring.gsub(s, "%.$", "")
	return s
end

-- listToText: join an array into a readable string.
local function listToText(list, sep)
	if type(list) ~= "table" or #list == 0 then
		return nil
	end
	return table.concat(list, sep or ", ")
end

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

-- addRow: add a standard <tr><th>Label</th><td>Value</td></tr> row.
local function addRow(tbl, label, value, rowClass, dataKey)
	if value == nil or value == "" then
		return
	end

	local row = tbl:tag("tr")
	row:addClass("sv-row")
	if rowClass then row:addClass(rowClass) end
	if dataKey then row:attr("data-field", dataKey) end

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

-- formatUnitValue: format {Value, Unit} blocks (or scalar) for display.
local function formatUnitValue(v)
	if type(v) == "table" and v.Value ~= nil then
		local unit = v.Unit
		local val  = v.Value

		if unit == "percent_decimal" or unit == "percent_whole" or unit == "percent" then
			return tostring(val) .. "%"
		elseif unit == "seconds" then
			return tostring(val) .. "s"
		elseif unit == "meters" then
			return tostring(val) .. "m"
		elseif unit == "tiles" then
			return tostring(val) .. " tiles"
		elseif unit and unit ~= "" then
			return tostring(val) .. " " .. tostring(unit)
		else
			return tostring(val)
		end
	end

	return (v ~= nil) and tostring(v) or nil
end

----------------------------------------------------------------------
-- Dynamic spans (JS-driven)
----------------------------------------------------------------------

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

	level = clamp(level or #series, 1, #series)

	local span = mw.html.create("span")
	span:addClass("sv-dyn")
	span:attr("data-series", mw.text.jsonEncode(series))
	span:wikitext(mw.text.nowiki(series[level] or ""))

	return tostring(span)
end

-- isFlatList: true if all values in list are identical.
local function isFlatList(list)
	if type(list) ~= "table" or #list == 0 then
		return false
	end
	local first = tostring(list[1])
	for i = 2, #list do
		if tostring(list[i]) ~= first then
			return false
		end
	end
	return true
end

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

-- isZeroish: aggressively treat common “zero” text forms as zero.
local function isZeroish(v)
	if v == nil then return true end
	if type(v) == "number" then return v == 0 end
	if type(v) == "table" and v.Value ~= nil then
		return isZeroish(v.Value)
	end

	local s = mw.text.trim(tostring(v))
	if s == "" then return true end
	if s == "0" or s == "0.0" or s == "0.00" then return true end
	if s == "0s" or s == "0 s" then return true end
	if s == "0m" or s == "0 m" then return true end
	if s == "0%" or s == "0 %" then return true end

	local n = tonumber((mw.ustring.gsub(s, "[^0-9%.%-]", "")))
	return (n ~= nil and n == 0)
end

-- valuePairRawText: render Base/Per Level blocks into readable text (fallback).
local function valuePairRawText(block)
	if type(block) ~= "table" then
		return nil
	end

	local base = block.Base
	local per  = block["Per Level"]

	if type(per) == "table" then
		if #per == 0 then
			return formatUnitValue(base)
		end
		if isFlatList(per) then
			return formatUnitValue(base) or tostring(per[1])
		end

		local vals = {}
		for _, v in ipairs(per) do
			table.insert(vals, formatUnitValue(v) or tostring(v))
		end
		return (#vals > 0) and table.concat(vals, " / ") or nil
	end

	local baseText = formatUnitValue(base)
	local perText  = formatUnitValue(per)

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

	return baseText or perText
end

-- valuePairDynamicValueOnly: render Base/Per Level blocks using dyn spans where possible.
local function valuePairDynamicValueOnly(block, maxLevel, level)
	if type(block) ~= "table" then
		return nil
	end

	local base = block.Base
	local per  = block["Per Level"]

	if type(per) == "table" then
		if #per == 0 then
			local baseText = formatUnitValue(base)
			return baseText and mw.text.nowiki(baseText) or nil
		end

		if isFlatList(per) then
			local one  = formatUnitValue(per[1]) or tostring(per[1])
			local show = formatUnitValue(base) or one
			return show and mw.text.nowiki(show) or nil
		end

		local series = {}
		for _, v in ipairs(per) do
			table.insert(series, formatUnitValue(v) or tostring(v))
		end
		return dynSpan(series, level)
	end

	local txt = valuePairRawText(block)
	return txt and mw.text.nowiki(txt) or nil
end

----------------------------------------------------------------------
-- Lookups
----------------------------------------------------------------------

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

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

	local dataset = getSkills()
	local byName = dataset.byName or {}

	if byName[name] then
		return byName[name]
	end

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

	return nil
end

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

-- basisLabel: label ATK/MATK basis in legacy damage blocks.
local function basisLabel(entry, isHealing)
	if isHealing then
		return "Healing"
	end

	local atk  = entry and entry["ATK-Based"]
	local matk = entry and entry["MATK-Based"]

	if atk and matk then
		return "Attack/Magic Attack"
	elseif atk then
		return "Attack"
	elseif matk then
		return "Magic Attack"
	end

	return "Damage"
end

-- formatDamageEntry: legacy percent damage formatting (dynamic by level).
local function formatDamageEntry(entry, maxLevel, level)
	if type(entry) ~= "table" then
		return nil
	end

	local isHealing = (entry.Type == "Healing")
	local basis = isHealing and "Healing" or basisLabel(entry, false)

	local baseRaw = entry["Base %"]
	local perRaw  = entry["Per Level %"]

	local baseN = toNum(baseRaw)
	local perN  = toNum(perRaw)

	local function baseIsPresent()
		if baseN ~= nil then
			return baseN ~= 0
		end
		if baseRaw ~= nil then
			local s = tostring(baseRaw)
			return (s ~= "" and s ~= "0" and s ~= "0.0" and s ~= "0.00")
		end
		return false
	end

	local baseText
	if baseIsPresent() then
		baseText = (baseN ~= nil) and (fmtNum(baseN) .. "%") or (tostring(baseRaw) .. "%")
	end

	if perN == nil or perN == 0 or not maxLevel or maxLevel <= 0 then
		return baseText and mw.text.nowiki(baseText .. " " .. basis) or nil
	end

	local series = {}
	for lv = 1, maxLevel do
		local perPart = perN * lv

		if baseText and baseN ~= nil then
			local total = baseN + perPart
			table.insert(series, string.format("%s%% %s", fmtNum(total), basis))
		elseif baseText then
			table.insert(series, string.format("%s + %s%% %s", baseText, fmtNum(perPart), basis))
		else
			table.insert(series, string.format("%s%% %s", fmtNum(perPart), basis))
		end
	end

	return dynSpan(series, level)
end

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

	local parts = {}
	for _, d in ipairs(list) do
		if type(d) == "table" then
			local txt = formatDamageEntry(d, maxLevel, level)
			if txt then
				if includeTypePrefix and d.Type and d.Type ~= "" then
					table.insert(parts, mw.text.nowiki(tostring(d.Type) .. ": ") .. txt)
				else
					table.insert(parts, txt)
				end
			end
		end
	end

	return (#parts > 0) and table.concat(parts, "<br />") or nil
end

----------------------------------------------------------------------
-- Users matching
----------------------------------------------------------------------

-- skillMatchesUser: check if a skill is used by a specific class/monster/summon/event.
local function skillMatchesUser(rec, userName)
	if type(rec) ~= "table" or not userName or userName == "" then
		return false
	end

	local users = rec.Users
	if type(users) ~= "table" then
		return false
	end

	local userLower = mw.ustring.lower(userName)

	local function listHas(list)
		if type(list) ~= "table" then
			return false
		end
		for _, v in ipairs(list) do
			if type(v) == "string" and mw.ustring.lower(v) == userLower then
				return true
			end
		end
		return false
	end

	return listHas(users.Classes) or listHas(users.Summons) or listHas(users.Monsters) or listHas(users.Events)
end

----------------------------------------------------------------------
-- Hide Users on direct skill pages
----------------------------------------------------------------------

-- isDirectSkillPage: hide Users rows when viewing the skill page itself.
local function isDirectSkillPage(rec)
	if type(rec) ~= "table" then
		return false
	end

	local pageTitle = mw.title.getCurrentTitle()
	local pageName  = pageTitle and pageTitle.text or ""
	pageName = trim(pageName)
	if not pageName then
		return false
	end

	pageName = mw.ustring.lower(pageName)

	local ext = trim(rec["External Name"] or rec.Name or rec["Display Name"])
	local internal = trim(rec["Internal Name"] or rec.InternalName or rec.InternalID)

	return (ext and mw.ustring.lower(ext) == pageName) or (internal and mw.ustring.lower(internal) == pageName) or false
end

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

local HERO_BAR_SLOT_ASSIGNMENT = {
	[1] = "IconName",
	[2] = "SkillType", -- Damage/Element/Hits/Target/Cast/Combo strip
}

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

----------------------------------------------------------------------
-- Slot scaffolds
----------------------------------------------------------------------

-- heroBarBox: wrapper for hero-bar slot modules.
local function heroBarBox(slot, extraClasses, innerHtml, isEmpty)
	local box = mw.html.create("div")
	box:addClass("hero-bar-module")
	box:addClass("hero-bar-module-" .. tostring(slot))
	box:attr("data-hero-bar-module", tostring(slot))

	if slot == 2 then
		box:addClass("sv-herobar-compact")
	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")
	if innerHtml and innerHtml ~= "" then
		body:wikitext(innerHtml)
	end

	return tostring(box)
end

-- moduleBox: wrapper for hero-module (2x2 grid) slot modules.
local function moduleBox(slot, extraClasses, innerHtml, isEmpty)
	local box = mw.html.create("div")
	box:addClass("hero-module")
	box:addClass("hero-module-" .. tostring(slot))
	box:attr("data-hero-module", tostring(slot))

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

	if isEmpty then
		box:addClass("hero-module-empty")
	end

	local body = box:tag("div"):addClass("hero-module-body")
	if innerHtml and innerHtml ~= "" then
		body:wikitext(innerHtml)
	end

	return tostring(box)
end

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

-- formatScalingCompactLines: build compact “Scaling” lines for SourceType.
local function formatScalingCompactLines(scaling)
	if type(scaling) ~= "table" then
		return {}
	end

	local list = scaling
	if #list == 0 then
		if scaling.Percent ~= nil or scaling["Scaling ID"] or scaling["Scaling Name"] then
			list = { scaling }
		else
			return {}
		end
	end

	local out = {}
	for _, s in ipairs(list) do
		if type(s) == "table" then
			local stat = s["Scaling Name"] or s["Scaling ID"] or "Unknown"
			local pct  = s.Percent
			local pctN = toNum(pct)

			if pctN ~= nil and pctN ~= 0 then
				table.insert(out, string.format("%s%% %s", fmtNum(pctN), stat))
			elseif pct ~= nil and tostring(pct) ~= "" and tostring(pct) ~= "0" then
				table.insert(out, string.format("%s%% %s", tostring(pct), stat))
			end
		end
	end

	return out
end

-- basisWordFromFlags: compute “Attack/Magic/Hybrid” from ATK/MATK booleans.
local function basisWordFromFlags(atkFlag, matkFlag)
	if atkFlag and matkFlag then
		return "Hybrid"
	elseif atkFlag then
		return "Attack"
	elseif matkFlag then
		return "Magic Attack"
	end
	return nil
end

-- legacyPercentAtLevel: compute “Base% + PerLevel%*level” for legacy entries.
local function legacyPercentAtLevel(entry, level)
	if type(entry) ~= "table" then
		return nil
	end

	local baseRaw = entry["Base %"]
	local perRaw  = entry["Per Level %"]
	local baseN   = toNum(baseRaw)
	local perN    = toNum(perRaw)

	if perN ~= nil and perN ~= 0 then
		local total = (baseN or 0) + (perN * level)
		return fmtNum(total) .. "%"
	end

	if baseN ~= nil then
		return fmtNum(baseN) .. "%"
	end
	if baseRaw ~= nil and tostring(baseRaw) ~= "" then
		return tostring(baseRaw) .. "%"
	end

	return nil
end

-- seriesFromValuePair: normalize Base/Per Level blocks into a level-indexed series.
local function seriesFromValuePair(block, maxLevel)
	if type(block) ~= "table" then
		return nil
	end

	local base = block.Base
	local per  = block["Per Level"]

	local function pickUnit(v)
		if type(v) == "table" and v.Unit and v.Unit ~= "" then
			return v.Unit
		end
		return nil
	end
	local unit = pickUnit(base) or pickUnit(per)

	local function fmtAny(v)
		local t = formatUnitValue(v)
		return t and tostring(t) or (v ~= nil and tostring(v) or nil)
	end

	local series = {}

	-- Expanded per-level series (wikiprep)
	if type(per) == "table" and #per > 0 then
		for lv = 1, maxLevel do
			local raw = per[lv] or per[#per]
			local one = fmtAny(raw)
			if one == nil or isZeroish(raw) or isZeroish(one) then
				one = "—"
			end
			series[lv] = one
		end
		return series
	end

	-- Empty per-list -> base only
	if type(per) == "table" and #per == 0 then
		local one = fmtAny(base)
		if one == nil or isZeroish(base) or isZeroish(one) then
			one = "—"
		end
		for lv = 1, maxLevel do
			series[lv] = one
		end
		return series
	end

	-- Scalar per -> compute base + per*level (fallback)
	local baseN = toNum(base) or 0
	local perN  = toNum(per)

	if perN ~= nil then
		for lv = 1, maxLevel do
			local total = baseN + (perN * lv)
			local v = unit and { Value = total, Unit = unit } or total
			local one = fmtAny(v)
			if one == nil or total == 0 or isZeroish(one) then
				one = "—"
			end
			series[lv] = one
		end
		return series
	end

	-- Base-only scalar
	local raw = (base ~= nil) and base or per
	local one = fmtAny(raw)
	if one == nil then
		return nil
	end
	if isZeroish(raw) or isZeroish(one) then
		one = "—"
	end
	for lv = 1, maxLevel do
		series[lv] = one
	end
	return series
end

-- displayFromSeries: render a series as fixed text or dynSpan (nil if all “—”).
local function displayFromSeries(series, level)
	if type(series) ~= "table" or #series == 0 then
		return nil
	end

	local any = false
	for _, v in ipairs(series) do
		if v ~= "—" then
			any = true
			break
		end
	end
	if not any then
		return nil
	end

	if isFlatList(series) then
		return mw.text.nowiki(series[1])
	end
	return dynSpan(series, level)
end

-- 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
		return nil
	end

	-- Helper: pull a number from scalar/unit/valuepair-ish things.
	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
	end

	local sizeName = nil
	if type(rawSize) == "table" then
		sizeName = rawSize.Name or rawSize.ID or rawSize.Value
	elseif type(rawSize) == "string" then
		sizeName = rawSize
	elseif type(rawSize) == "number" then
		-- uncommon; treat as numeric-only label
		sizeName = tostring(rawSize)
	end

	sizeName = sizeName and mw.text.trim(tostring(sizeName)) or nil
	if not sizeName or sizeName == "" or isNoneLike(sizeName) then
		return nil
	end

	-- 2) Find the numeric “exact number” to append
	-- Prefer the explicit Area Distance block, then fall back to other known numeric keys.
	local num = nil

	local dist = area["Area Distance"]
	if type(dist) == "table" then
		-- Prefer Effective Distance if present and non-zero, else Base
		num = extractNumber(dist["Effective Distance"]) or extractNumber(dist.Effective) or extractNumber(dist["Effective"])
		if not num or num == 0 then
			num = extractNumber(dist.Base) or extractNumber(dist["Base"])
		end
	end

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

	-- 3) Render
	-- If size already contains parentheses, assume it already includes the numeric.
	if mw.ustring.find(sizeName, "%(") then
		return mw.text.nowiki(sizeName)
	end

	if num and num ~= 0 then
		return mw.text.nowiki(string.format("%s (%s)", sizeName, fmtNum(num)))
	end

	return mw.text.nowiki(sizeName)
end

-- skillHasAnyDamage: determine if a skill has any meaningful damage (for non-damaging rules).
local function skillHasAnyDamage(rec, maxLevel)
	if type(rec.Source) == "table" then
		local s = seriesFromValuePair(rec.Source, maxLevel)
		if s then
			for _, v in ipairs(s) do
				if v ~= "—" then return true end
			end
		end
	end

	if type(rec.Damage) == "table" then
		local dmg = rec.Damage
		for _, key in ipairs({ "Main Damage", "Flat Damage", "Reflect Damage" }) do
			local lst = dmg[key]
			if type(lst) == "table" and #lst > 0 then
				return true
			end
		end
	end

	return false
end

-- computeDurationPromotion: promote status-duration into QuickStats when a skill is non-damaging.
local function computeDurationPromotion(rec, maxLevel)
	if type(rec) ~= "table" then return nil end
	if skillHasAnyDamage(rec, maxLevel) then return nil end

	local mech = (type(rec.Mechanics) == "table") and rec.Mechanics or {}
	local bt   = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
	local durS = seriesFromValuePair(bt["Duration"], maxLevel)

	if durS ~= nil then
		local any = false
		for _, v in ipairs(durS) do
			if v ~= "—" then any = true break end
		end
		if any then
			return nil
		end
	end

	local apps = rec["Status Applications"]
	if type(apps) ~= "table" then return nil end

	for idx, app in ipairs(apps) do
		if type(app) == "table" and type(app.Duration) == "table" then
			local s = seriesFromValuePair(app.Duration, maxLevel)
			if s then
				for _, v in ipairs(s) do
					if v ~= "—" then
						return {
							durationBlock = app.Duration,
							suppressDurationIndex = idx,
						}
					end
				end
			end
		end
	end

	return nil
end

----------------------------------------------------------------------
-- Plug-ins
----------------------------------------------------------------------

local PLUGINS = {}

-- PLUGIN: IconName (Hero Bar Slot 1) - icon + name.
function PLUGINS.IconName(rec, ctx)
	local icon  = rec.Icon
	local title = rec["External Name"] or rec.Name or rec["Internal Name"] or "Unknown Skill"

	local wrap = mw.html.create("div")
	wrap:addClass("sv-herobar-1-wrap")

	if icon and icon ~= "" then
		wrap:tag("div")
			:addClass("sv-herobar-icon")
			:wikitext(string.format("[[File:%s|80px|link=]]", icon))
	end

	wrap:tag("div")
		:addClass("spiritvale-infobox-title")
		:wikitext(title)

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

-- PLUGIN: SkillType (Hero Bar Slot 2) - 2 rows x 3 cells (desktop + mobile).
-- Rules:
--   - If skill is non-damaging, hide Damage/Element/Hits.
--   - If Hits is empty, hide Hits.
--   - If Combo is empty, hide Combo.
-- Ordering:
--   - Desktop: Damage, Element, Hits, Target, Cast, Combo
--   - Mobile:  Damage, Element, Target, Cast, Hits, Combo (CSS reorder)
function PLUGINS.SkillType(rec, ctx)
	local typeBlock = (type(rec.Type) == "table") and rec.Type or {}
	local mech      = (type(rec.Mechanics) == "table") and rec.Mechanics or {}

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

	local hideDamageBundle = (ctx.nonDamaging == true)

	-- valName: extract a display string from typical {Name/ID/Value} objects.
	-- NOTE: Includes number support so Hits=2 (number) doesn't get dropped.
	local function valName(x)
		if x == nil then return nil end
		if type(x) == "table" then
			if x.Name and x.Name ~= "" then return tostring(x.Name) end
			if x.ID and x.ID ~= "" then return tostring(x.ID) end
			if x.Value ~= nil then return tostring(x.Value) end
		end
		if type(x) == "number" then
			return tostring(x)
		end
		if type(x) == "string" and x ~= "" then
			return x
		end
		return nil
	end

	-- hitsDisplay: find + render Hits from multiple possible structured locations.
	local function hitsDisplay()
		local effects = (type(mech.Effects) == "table") and mech.Effects or {}

		local h =
			typeBlock.Hits or typeBlock["Hits"] or typeBlock["Hit Count"] or typeBlock["Hits Count"] or
			mech.Hits or mech["Hits"] or mech["Hit Count"] or mech["Hits Count"] or
			effects.Hits or effects["Hits"] or effects["Hit Count"] or effects["Hits Count"] or
			rec.Hits or rec["Hits"]

		if h == nil or isNoneLike(h) then
			return nil
		end

		-- ValuePair-style table (Base/Per Level) => dynamic series
		if type(h) == "table" then
			if h.Base ~= nil or h["Per Level"] ~= nil or type(h["Per Level"]) == "table" then
				return displayFromSeries(seriesFromValuePair(h, maxLevel), level)
			end

			-- Unit block {Value, Unit}
			if h.Value ~= nil then
				local t = formatUnitValue(h)
				return t and mw.text.nowiki(t) or nil
			end

			-- Fallback name extraction
			local function valName(x)
				if x == nil then return nil end
				if type(x) == "table" then
					if x.Name and x.Name ~= "" then return tostring(x.Name) end
					if x.ID and x.ID ~= "" then return tostring(x.ID) end
					if x.Value ~= nil then return tostring(x.Value) end
				end
				if type(x) == "number" then return tostring(x) end
				if type(x) == "string" and x ~= "" then return x end
				return nil
			end

			local vn = valName(h)
			if vn and not isNoneLike(vn) then
				return mw.text.nowiki(vn)
			end
		end

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

		return nil
	end

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

		local typ = trim(c.Type)
		if not typ or isNoneLike(typ) then
			return nil
		end

		local details = {}

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

		local dur = formatUnitValue(c.Duration)
		if dur and not isZeroish(dur) then
			table.insert(details, mw.text.nowiki(dur))
		end

		if #details > 0 then
			return mw.text.nowiki(typ) .. " (" .. table.concat(details, ", ") .. ")"
		end
		return mw.text.nowiki(typ)
	end

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

	local added = false

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

		local chunk = grid:tag("div")
			:addClass("sv-type-chunk")
			:addClass("sv-type-" .. tostring(key))
			:attr("data-type-key", tostring(key))

		chunk:tag("div")
			:addClass("sv-type-label")
			:wikitext(mw.text.nowiki(label))

		chunk:tag("div")
			:addClass("sv-type-value")
			:wikitext(valueHtml)
	end

	-- Damage + Element + Hits bundle (hidden when non-damaging)
	if not hideDamageBundle then
		local dmg  = valName(typeBlock.Damage or typeBlock["Damage Type"])
		local ele  = valName(typeBlock.Element or typeBlock["Element Type"])
		local hits = hitsDisplay()

		if dmg and not isNoneLike(dmg) then
			addChunk("damage", "Damage", mw.text.nowiki(dmg))
		end
		if ele and not isNoneLike(ele) then
			addChunk("element", "Element", mw.text.nowiki(ele))
		end
		if hits then
			addChunk("hits", "Hits", hits)
		end
	end

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

	if tgt and not isNoneLike(tgt) then
		addChunk("target", "Target", mw.text.nowiki(tgt))
	end
	if cst and not isNoneLike(cst) then
		addChunk("cast", "Cast", mw.text.nowiki(cst))
	end

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

	return {
		inner = added and tostring(grid) or "",
		classes = "module-skill-type",
	}
end

-- PLUGIN: SourceType (Hero Module Slot 1) - Modifier + Source + Scaling.
function PLUGINS.SourceType(rec, ctx)
	local level = ctx.level or 1
	local maxLevel = ctx.maxLevel or 1

	local basisWord = nil
	local sourceKind = nil
	local sourceVal  = 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
		local src = rec.Source
		local atkFlag  = (src["ATK-Based"] == true)
		local matkFlag = (src["MATK-Based"] == true)
		basisWord = basisWordFromFlags(atkFlag, matkFlag)

		sourceKind = src.Type or ((src.Healing == true) and "Healing") or "Damage"
		sourceVal  = sourceValueForLevel(src)
		scaling    = src.Scaling
	end

	-- Fallback to legacy Damage lists if Source absent
	if (sourceVal == nil or sourceVal == "") and type(rec.Damage) == "table" then
		local dmg = rec.Damage
		scaling = scaling or dmg.Scaling

		local main = dmg["Main Damage"]
		local refl = dmg["Reflect Damage"]
		local flat = dmg["Flat Damage"]

		if type(main) == "table" and #main > 0 then
			local pick = nil
			for _, d in ipairs(main) do
				if type(d) == "table" and d.Type ~= "Healing" then
					pick = d
					break
				end
			end
			pick = pick or main[1]

			if type(pick) == "table" then
				local atkFlag  = (pick["ATK-Based"] == true)
				local matkFlag = (pick["MATK-Based"] == true)
				basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)

				sourceKind = (pick.Type == "Healing") and "Healing" or "Damage"
				sourceVal  = legacyPercentAtLevel(pick, level)
			end
		elseif type(refl) == "table" and #refl > 0 and type(refl[1]) == "table" then
			local pick = refl[1]
			local atkFlag  = (pick["ATK-Based"] == true)
			local matkFlag = (pick["MATK-Based"] == true)
			basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)

			sourceKind = "Reflect"
			sourceVal  = legacyPercentAtLevel(pick, level)
		elseif type(flat) == "table" and #flat > 0 and type(flat[1]) == "table" then
			local pick = flat[1]
			local atkFlag  = (pick["ATK-Based"] == true)
			local matkFlag = (pick["MATK-Based"] == true)
			basisWord = basisWord or basisWordFromFlags(atkFlag, matkFlag)

			sourceKind = "Flat"
			sourceVal  = legacyPercentAtLevel(pick, level)
		end
	end

	local scalingLines = formatScalingCompactLines(scaling)
	local hasSource    = (sourceVal ~= nil and tostring(sourceVal) ~= "")
	local hasScaling   = (type(scalingLines) == "table" and #scalingLines > 0)

	if (not hasSource) and (not hasScaling) then
		return nil
	end

	local hasMod = (basisWord ~= nil and tostring(basisWord) ~= "")

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

	if hasSource and (not hasScaling) then
		table.insert(extra, "sv-only-source")
	elseif hasScaling and (not hasSource) then
		table.insert(extra, "sv-only-scaling")
	end

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

	if hasMod then
		local modCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-modifier")
		modCol:tag("div"):addClass("sv-source-pill"):wikitext("Modifier")
		modCol:tag("div"):addClass("sv-modifier-value"):wikitext(mw.text.nowiki(basisWord))
	end

	if hasSource then
		local sourceCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-main")
		sourceCol:tag("div"):addClass("sv-source-pill"):wikitext(mw.text.nowiki(sourceKind or "Source"))
		sourceCol:tag("div"):addClass("sv-source-value"):wikitext(sourceVal)
	end

	if hasScaling then
		local scalingCol = wrap:tag("div"):addClass("sv-source-col"):addClass("sv-source-scaling")
		scalingCol:tag("div"):addClass("sv-source-pill"):wikitext("Scaling")

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

	return {
		inner = tostring(wrap),
		classes = extra,
	}
end

-- PLUGIN: QuickStats (Hero Module Slot 2) - 3x2 grid (range/area/cost/cast/cd/duration).
-- NOTE: Hits does NOT live here (it lives in SkillType).
function PLUGINS.QuickStats(rec, ctx)
	local level = ctx.level or 1
	local maxLevel = ctx.maxLevel or 1
	local promo = ctx.promo

	local mech = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
	local bt   = (type(mech["Basic Timings"]) == "table") and mech["Basic Timings"] or {}
	local rc   = (type(mech["Resource Cost"]) == "table") and mech["Resource Cost"] or {}

	local function dash() return "—" end

	-- Range (0 => —)
	local rangeVal = nil
	if mech.Range ~= nil and not isNoneLike(mech.Range) then
		local n = toNum(mech.Range)
		if n ~= nil then
			if n ~= 0 then
				rangeVal = mw.text.nowiki(formatUnitValue(mech.Range) or tostring(mech.Range))
			end
		else
			local t = mw.text.trim(tostring(mech.Range))
			if t ~= "" and not isNoneLike(t) then
				rangeVal = mw.text.nowiki(t)
			end
		end
	end

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

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

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

	-- Cost: MP + HP
	local function labeledSeries(block, label)
		local s = seriesFromValuePair(block, maxLevel)
		if not s then return nil end
		local any = false
		for i, v in ipairs(s) do
			if v ~= "—" then
				s[i] = tostring(v) .. " " .. label
				any = true
			else
				s[i] = "—"
			end
		end
		return any and s or nil
	end

	local mpS = labeledSeries(rc["Mana Cost"], "MP")
	local hpS = labeledSeries(rc["Health Cost"], "HP")

	local costSeries = {}
	for lv = 1, maxLevel do
		local mp = mpS and mpS[lv] or "—"
		local hp = hpS and hpS[lv] or "—"

		if mp ~= "—" and hp ~= "—" then
			costSeries[lv] = mp .. " + " .. hp
		elseif mp ~= "—" then
			costSeries[lv] = mp
		elseif hp ~= "—" then
			costSeries[lv] = hp
		else
			costSeries[lv] = "—"
		end
	end

	local costVal = displayFromSeries(costSeries, level)

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

	local function addCell(label, val)
		local cell = grid:tag("div"):addClass("sv-m4-cell")
		cell:tag("div"):addClass("sv-m4-label"):wikitext(mw.text.nowiki(label))
		cell:tag("div"):addClass("sv-m4-value"):wikitext(val or dash())
	end

	addCell("Range",     rangeVal)
	addCell("Area",      areaVal)
	addCell("Cost",      costVal)
	addCell("Cast Time", castVal)
	addCell("Cooldown",  cdVal)
	addCell("Duration",  durVal)

	return {
		inner = tostring(grid),
		classes = "module-quick-stats",
	}
end

-- PLUGIN: SpecialMechanics (Hero Module Slot 3)
-- Shows:
--   - Flags (deduped)
--   - Special mechanics (mech.Effects)
-- NOTE: Combo lives in SkillType (Hero Bar Slot 2).
function PLUGINS.SpecialMechanics(rec, ctx)
	local level = ctx.level or 1
	local maxLevel = ctx.maxLevel or 1

	local mech    = (type(rec) == "table" and type(rec.Mechanics) == "table") and rec.Mechanics or {}
	local effects = (type(mech.Effects) == "table") and mech.Effects or nil
	local mods    = (type(rec.Modifiers) == "table") and rec.Modifiers or nil

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

	------------------------------------------------------------------
	-- Flags (flat, de-duped)
	------------------------------------------------------------------
	local flagSet = {}

	local denyFlags = {
		["self centered"] = true,
		["self-centred"] = true,
		["bond"] = true,
		["combo"] = true,
        ["hybrid"] = true,

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

	local function allowFlag(name)
		if not name then return false end
		local k = mw.ustring.lower(mw.text.trim(tostring(name)))
		if k == "" then return false end
		if denyFlags[k] then return false end
		return true
	end

	local function addFlags(sub)
		if type(sub) ~= "table" then return end
		for k, v in pairs(sub) do
			if v and allowFlag(k) then
				flagSet[tostring(k)] = true
			end
		end
	end

	if mods then
		addFlags(mods["Movement Modifiers"])
		addFlags(mods["Combat Modifiers"])
		addFlags(mods["Special Modifiers"])
		for k, v in pairs(mods) do
			if type(v) == "boolean" and v and allowFlag(k) then
				flagSet[tostring(k)] = true
			end
		end
	end

	local flags = {}
	for k, _ in pairs(flagSet) do table.insert(flags, k) end
	table.sort(flags)

	------------------------------------------------------------------
	-- Special mechanics (name => value)
	------------------------------------------------------------------
	local mechItems = {}

	if effects then
		local keys = {}
		for k, _ in pairs(effects) do table.insert(keys, k) end
		table.sort(keys)

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

						local value = disp

						-- If Type exists and is distinct, prefix it.
						if t and not isNoneLike(t) and mw.ustring.lower(t) ~= mw.ustring.lower(tostring(name)) then
							if value then
								value = mw.text.nowiki(t) .. ": " .. value
							else
								value = mw.text.nowiki(t)
							end
						end

						if value then
							table.insert(mechItems, { label = tostring(name), value = value })
						end
					end
				end
			end
		end
	end

	local hasFlags = (#flags > 0)
	local hasMech  = (#mechItems > 0)

	if (not hasFlags) and (not hasMech) then
		local root = mw.html.create("div")
		root:addClass("sv-sm-root")
		root:addClass("sv-compact-root")
		root:tag("div"):addClass("sv-sm-empty"):wikitext("No Special Mechanics")

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

	local count = 0
	if hasFlags then count = count + 1 end
	if hasMech  then count = count + 1 end

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

	local layout = root:tag("div"):addClass("sv-sm-layout")
	layout:addClass("sv-sm-count-" .. tostring(count))

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

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

----------------------------------------------------------------------
-- Generic slot renderers
----------------------------------------------------------------------

-- normalizeResult: normalize plugin return values into {inner, classes}.
local function normalizeResult(res)
	if res == nil then return nil end
	if type(res) == "string" then
		return { inner = res, classes = nil }
	end
	if type(res) == "table" then
		local inner = res.inner
		if type(inner) ~= "string" then
			inner = (inner ~= nil) and tostring(inner) or ""
		end
		return { inner = inner, classes = res.classes }
	end
	return { inner = tostring(res), classes = nil }
end

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

-- renderHeroBarSlot: render a hero-bar slot by plugin assignment.
local function renderHeroBarSlot(slotIndex, rec, ctx)
	local pluginName = HERO_BAR_SLOT_ASSIGNMENT[slotIndex]
	if not pluginName then
		return heroBarBox(slotIndex, nil, "", true)
	end

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

	return heroBarBox(slotIndex, res.classes, res.inner, false)
end

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

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

	return moduleBox(slotIndex, res.classes, res.inner, false)
end

----------------------------------------------------------------------
-- UI builders
----------------------------------------------------------------------

-- buildHeroBarUI: build the top hero bar (2 slots).
local function buildHeroBarUI(rec, ctx)
	local bar = mw.html.create("div")
	bar:addClass("hero-bar-grid")
	bar:wikitext(renderHeroBarSlot(1, rec, ctx))
	bar:wikitext(renderHeroBarSlot(2, rec, ctx))
	return tostring(bar)
end

-- buildHeroModulesUI: build the 2x2 module grid row (4 slots).
local function buildHeroModulesUI(rec, ctx)
	local grid = mw.html.create("div")
	grid:addClass("hero-modules-grid")
	for slot = 1, 4 do
		grid:wikitext(renderModuleSlot(slot, rec, ctx))
	end
	return tostring(grid)
end

-- addHeroModulesRow: add the hero-modules row into the infobox table.
local function addHeroModulesRow(tbl, modulesUI)
	if not modulesUI or modulesUI == "" then
		return
	end

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

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

----------------------------------------------------------------------
-- Infobox builder
----------------------------------------------------------------------

-- buildInfobox: render a single skill infobox.
local function buildInfobox(rec, opts)
	opts = opts or {}
	local showUsers = (opts.showUsers ~= false)

	local maxLevel = tonumber(rec["Max Level"]) or 1
	if maxLevel < 1 then maxLevel = 1 end
	local level = clamp(maxLevel, 1, maxLevel)

	local ctx = {
		maxLevel = maxLevel,
		level = level,
		nonDamaging = false,
		promo = nil,
	}

	-- Non-damaging hides Damage/Element/Hits in SkillType
	do
		local dmgVal = nil
		if type(rec.Type) == "table" then
			dmgVal = rec.Type.Damage or rec.Type["Damage Type"]
			if type(dmgVal) == "table" then
				dmgVal = dmgVal.Name or dmgVal.ID or dmgVal.Value
			end
		end
		ctx.nonDamaging = isNoneLike(dmgVal) or (not skillHasAnyDamage(rec, maxLevel))
	end

	ctx.promo = computeDurationPromotion(rec, maxLevel)

	local root = mw.html.create("table")
	root:addClass("spiritvale-skill-infobox")
	root:addClass("sv-skill-card")
	root:attr("data-max-level", tostring(maxLevel))
	root:attr("data-level", tostring(level))

	if opts.inList then
		root:addClass("sv-skill-inlist")
	end

	local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
	if internalId then
		root:attr("data-skill-id", internalId)
	end

	local desc  = rec.Description or ""

	-- Hero Title Bar
	local heroRow = root:tag("tr")
	heroRow:addClass("spiritvale-infobox-main")
	heroRow:addClass("sv-hero-title-row")
	heroRow:addClass("hero-title-bar")

	local heroCell = heroRow:tag("th")
	heroCell:attr("colspan", 2)
	heroCell:addClass("sv-hero-title-cell")
	heroCell:wikitext(buildHeroBarUI(rec, ctx))

	-- Description Bar
	if desc ~= "" then
		local descRow = root:tag("tr")
		descRow:addClass("spiritvale-infobox-main")
		descRow:addClass("sv-hero-desc-row")
		descRow:addClass("hero-description-bar")

		local descCell = descRow:tag("td")
		descCell:attr("colspan", 2)
		descCell:addClass("sv-hero-desc-cell")

		local descInner = descCell:tag("div")
		descInner:addClass("spiritvale-infobox-main-right-inner")

		descInner:tag("div")
			:addClass("spiritvale-infobox-description")
			:wikitext(string.format("''%s''", desc))
	end

	-- Modules row
	addHeroModulesRow(root, buildHeroModulesUI(rec, ctx))

	-- Users (hide on direct skill page)
	if showUsers then
		local users = rec.Users or {}
		addRow(root, "Classes",  listToText(users.Classes),  "sv-row-users", "Users.Classes")
		addRow(root, "Summons",  listToText(users.Summons),  "sv-row-users", "Users.Summons")
		addRow(root, "Monsters", listToText(users.Monsters), "sv-row-users", "Users.Monsters")
		addRow(root, "Events",   listToText(users.Events),   "sv-row-users", "Users.Events")
	end

	-- Requirements
	local req = rec.Requirements or {}
	local hasReq =
		(type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0) or
		(type(req["Required Weapons"]) == "table" and #req["Required Weapons"] > 0) or
		(type(req["Required Stances"]) == "table" and #req["Required Stances"] > 0)

	if hasReq then
		if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
			local skillParts = {}
			for _, rs in ipairs(req["Required Skills"]) do
				local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or "Unknown"
				local lvlReq  = rs["Required Level"]
				if lvlReq then
					table.insert(skillParts, string.format("%s (Lv.%s)", nameReq, lvlReq))
				else
					table.insert(skillParts, nameReq)
				end
			end
			addRow(root, "Required Skills", table.concat(skillParts, ", "), "sv-row-req", "Requirements.Required Skills")
		end

		addRow(root, "Required Weapons", listToText(req["Required Weapons"]), "sv-row-req", "Requirements.Required Weapons")
		addRow(root, "Required Stances", listToText(req["Required Stances"]), "sv-row-req", "Requirements.Required Stances")
	end

	-- Mechanics (keep small extras only)
	local mech = rec.Mechanics or {}
	if next(mech) ~= nil then
		if mech["Autocast Multiplier"] ~= nil then
			addRow(root, "Autocast Multiplier", tostring(mech["Autocast Multiplier"]), "sv-row-mech", "Mechanics.Autocast Multiplier")
		end
	end

	-- Legacy damage breakdown (only when Source absent)
	if type(rec.Source) ~= "table" then
		local dmg = rec.Damage or {}
		if next(dmg) ~= nil then
			local main = dmg["Main Damage"]
			local mainNonHeal, healOnly = {}, {}

			if type(main) == "table" then
				for _, d in ipairs(main) do
					if type(d) == "table" and d.Type == "Healing" then
						table.insert(healOnly, d)
					else
						table.insert(mainNonHeal, d)
					end
				end
			end

			addRow(root, "Main Damage",    formatDamageList(mainNonHeal, maxLevel, level, (#mainNonHeal > 1)), "sv-row-source", "Damage.Main Damage")
			addRow(root, "Flat Damage",    formatDamageList(dmg["Flat Damage"], maxLevel, level, false),        "sv-row-source", "Damage.Flat Damage")
			addRow(root, "Reflect Damage", formatDamageList(dmg["Reflect Damage"], maxLevel, level, false),     "sv-row-source", "Damage.Reflect Damage")
			addRow(root, "Healing",        formatDamageList(healOnly, maxLevel, level, false),                  "sv-row-source", "Damage.Healing")
		end
	end

	-- Status rows
	local function formatStatusApplications(list, 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 = 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

	local suppressIdx = (type(ctx.promo) == "table") and ctx.promo.suppressDurationIndex or nil
	local statusApps = formatStatusApplications(rec["Status Applications"], suppressIdx)
	local statusRem  = formatStatusRemoval(rec["Status Removal"])
	if statusApps or statusRem then
		addRow(root, "Applies", statusApps, "sv-row-status", "Status Applications")
		addRow(root, "Removes", statusRem,  "sv-row-status", "Status Removal")
	end

	-- Events
	local function formatEvents(list)
		if type(list) ~= "table" or #list == 0 then return nil end
		local parts = {}
		for _, ev in ipairs(list) do
			if type(ev) == "table" then
				local action = 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
		return (#parts > 0) and table.concat(parts, "<br />") or nil
	end

	local eventsText = formatEvents(rec.Events)
	if eventsText then
		addRow(root, "Triggers", eventsText, "sv-row-meta", "Events")
	end

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

----------------------------------------------------------------------
-- Public: list all skills for a given user/class
----------------------------------------------------------------------

function p.listForUser(frame)
	local args = getArgs(frame)

	local userName = args.user or args[1]
	if not userName or userName == "" then
		userName = mw.title.getCurrentTitle().text
	end

	if not userName or userName == "" then
		return "<strong>No user name provided to Skill list.</strong>"
	end

	local dataset = getSkills()
	local matches = {}

	for _, rec in ipairs(dataset.records or {}) do
		if skillMatchesUser(rec, userName) then
			table.insert(matches, rec)
		end
	end

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

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

	for _, rec in ipairs(matches) do
		local item = root:tag("div"):addClass("sv-skill-item")
		item:wikitext(buildInfobox(rec, { showUsers = false, inList = true }))
	end

	return tostring(root)
end

----------------------------------------------------------------------
-- Public: single-skill or auto-list dispatcher
----------------------------------------------------------------------

function p.infobox(frame)
	local args = getArgs(frame)

	local raw1 = args[1]
	local name = args.name or raw1
	local id   = args.id

	local rec
	if name and name ~= "" then
		rec = findSkillByName(name)
	end
	if not rec and id and id ~= "" then
		rec = getSkillById(id)
	end

	if not rec then
		local pageTitle = mw.title.getCurrentTitle()
		local pageName  = pageTitle and pageTitle.text or ""

		local noExplicitArgs =
			(not raw1 or raw1 == "") and
			(not args.name or args.name == "") and
			(not id or id == "")

		if noExplicitArgs then
			return p.listForUser(frame)
		end

		if name and name ~= "" and name == pageName and (not id or id == "") then
			return p.listForUser(frame)
		end

		local label = name or id or "?"
		return string.format(
			"<strong>Unknown Skill:</strong> %s[[Category:Pages with unknown skill|%s]]",
			mw.text.nowiki(label),
			label
		)
	end

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

return p