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:GamePassives: Difference between revisions

From SpiritVale Wiki
m Protected "Module:GamePassives" ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite)) [cascading]
No edit summary
 
(13 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:GamePassives
-- Module:GamePassives
--
--
-- Renders passive skill data (from Data:passives.json) into an infobox-style table.
-- Phase 6.5+ Slot/Grid architecture (aligned with Module:GameSkills).
-- Data is loaded via Module:GameData.
--
--
-- Supported usage patterns (via Template:Passive):
-- Layout:
--  {{Passive|Honed Blade}}             -> uses display Name (recommended)
--  Row 1: Slot 1 + Slot 2 (IconName + Description)
--  {{Passive|name=Honed Blade}}         -> explicit Name
--  Row 2: Slot 3 + Slot 4 (LevelSelector + PassiveEffects)
--  {{Passive|id=CritMastery}}           -> Internal Name (power use)
--
-- Requires Common.js:
--  - updates .sv-dyn spans via data-series
--  - updates .sv-level-num + data-level on .sv-passive-card
--  - binds to input.sv-level-range inside each card
--
-- Usage (single passive):
--  {{Passive|Honed Blade}}
--  {{Passive|name=Honed Blade}}
--  {{Passive|id=CritMastery}}
--
-- Usage (auto-list on class page, e.g. "Acolyte"):
--  {{Passive}}                -> lists all Acolyte passives (page name)
--  {{Passive|Acolyte}}        -> same, if no passive literally called "Acolyte"


local GameData = require("Module:GameData")
local GameData = require("Module:GameData")
Line 14: Line 26:


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


Line 20: Line 32:


local function getPassives()
local function getPassives()
    if not passivesCache then
        if not passivesCache then
        passivesCache = GameData.loadPassives()
                passivesCache = GameData.loadPassives()
    end
        end
    return passivesCache
        return passivesCache
end
end
----------------------------------------------------------------------
-- Small utilities
----------------------------------------------------------------------


local function getArgs(frame)
local function getArgs(frame)
    local parent = frame:getParent()
        local parent = frame:getParent()
    if parent then
         return (parent and parent.args) or frame.args
         return parent.args
    end
    return frame.args
end
end


local function listToText(list)
local function trim(s)
    if type(list) ~= "table" or #list == 0 then
        if type(s) ~= "string" then
                return nil
        end
        s = mw.text.trim(s)
        return (s ~= "" and s) or nil
end
 
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
         return nil
    end
    return table.concat(list, ", ")
end
end


local function addRow(tbl, label, value)
local function clamp(n, lo, hi)
    if value == nil or value == "" then
        if type(n) ~= "number" then
        return
                return lo
    end
        end
    local row = tbl:tag("tr")
        if n < lo then return lo end
    row:tag("th"):wikitext(label):done()
        if n > hi then return hi end
    row:tag("td"):wikitext(value):done()
        return n
end
end


-- Lookup by Internal Name
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
 
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
 
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
 
----------------------------------------------------------------------
-- Dynamic spans (JS-driven)
----------------------------------------------------------------------
 
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
 
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)
                if type(v) == "table" and v.Value ~= nil then
                        if v.Unit then
                                return tostring(v.Value) .. " " .. tostring(v.Unit)
                        end
                        return tostring(v.Value)
                end
                return (v ~= nil) and tostring(v) or nil
        end
 
        local series = {}
 
        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 isNoneLike(raw) or isNoneLike(one) or one == "0" then
                                one = "—"
                        end
                        series[lv] = one
                end
                return series
        end
 
        if type(per) == "table" and #per == 0 then
                local one = fmtAny(base)
                if one == nil or isNoneLike(base) or isNoneLike(one) or one == "0" then
                        one = "—"
                end
                for lv = 1, maxLevel do
                        series[lv] = one
                end
                return series
        end
 
        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 then
                                one = "—"
                        end
                        series[lv] = one
                end
                return series
        end
 
        local raw = (base ~= nil) and base or per
        local one = fmtAny(raw)
        if one == nil then
                return nil
        end
        if one == "0" or isNoneLike(raw) then
                one = "—"
        end
        for lv = 1, maxLevel do
                series[lv] = one
        end
        return series
end
 
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
 
----------------------------------------------------------------------
-- Lookups
----------------------------------------------------------------------
 
local function getPassiveById(id)
local function getPassiveById(id)
    if not id or id == "" then
        id = trim(id)
        return nil
        if not id then return nil end
    end
        local dataset = getPassives()
    local dataset = getPassives()
        local byId = dataset.byId or {}
    local byId = dataset.byId or {}
        return byId[id]
    return byId[id]
end
end


-- Lookup by display Name (for editors)
local function findPassiveByName(name)
local function findPassiveByName(name)
    if not name or name == "" then
        name = trim(name)
        if not name then return nil end
 
        local dataset = getPassives()
        for _, rec in ipairs(dataset.records or {}) do
                if type(rec) == "table" then
                        local display = rec["External Name"] or rec.Name or rec["Display Name"]
                        if display == name then
                                return rec
                        end
                end
        end
 
         return nil
         return nil
    end
end
    local dataset = getPassives()
 
    for _, rec in ipairs(dataset.records or {}) do
local function resolveDisplayName(rec)
         if rec["Name"] == name then
        if type(rec) ~= "table" then
            return rec
                return tostring(rec or "")
         end
        return rec["External Name"] or rec.Name or rec["Display Name"] or rec["Internal Name"] or rec.ID or "Unknown Passive"
end
 
----------------------------------------------------------------------
-- User matching (for auto lists on class pages)
----------------------------------------------------------------------
 
local function passiveMatchesUser(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
         end
    end
 
    return nil
        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
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Formatting helpers
-- Slot config
----------------------------------------------------------------------
 
local HERO_SLOT_ASSIGNMENT = {
        [1] = "IconName",
        [2] = "Description",
        [3] = "LevelSelector",
        [4] = "PassiveEffects",
}
 
local PLUGINS = {}
 
----------------------------------------------------------------------
-- Slot scaffolds
----------------------------------------------------------------------
----------------------------------------------------------------------


local function formatPassiveEffects(list)
local function slotBox(slot, extraClasses, innerHtml, opts)
    if type(list) ~= "table" or #list == 0 then
        opts = opts or {}
         return nil
 
    end
        local box = mw.html.create("div")
        box:addClass("sv-slot")
        box:addClass("sv-slot--" .. tostring(slot))
        box:attr("data-hero-slot", tostring(slot))
 
        if opts.isFull then
                box:addClass("sv-slot--full")
        end
 
        if extraClasses then
                if type(extraClasses) == "string" then
                        box:addClass(extraClasses)
                elseif type(extraClasses) == "table" then
                        for _, c in ipairs(extraClasses) do box:addClass(c) end
                end
        end
 
        if opts.isEmpty then
                box:addClass("sv-slot--empty")
        end
 
        local body = box:tag("div"):addClass("sv-slot__body")
        if innerHtml and innerHtml ~= "" then
                body:wikitext(innerHtml)
        end
 
        return tostring(box)
end
 
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
 
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
 
local function isEmptySlotContent(inner)
        if inner == nil then return true end
 
        local raw = tostring(inner)
 
        for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do
                if mw.ustring.find(raw, pat) then
                        return false
                end
        end
 
        local trimmed = mw.text.trim(raw)
        if trimmed == "" or trimmed == "—" then
                return true
        end
 
        local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", ""))
        return (withoutTags == "" or withoutTags == "—")
end
 
local function renderHeroSlot(slotIndex, rec, ctx)
        local pluginName = HERO_SLOT_ASSIGNMENT[slotIndex]
        if not pluginName then
                return nil
        end
 
        local res = safeCallPlugin(pluginName, rec, ctx)
        if not res or isEmptySlotContent(res.inner) then
                return nil
        end
 
        return {
                inner = res.inner,
                classes = res.classes,
        }
end


    local parts = {}
local function buildHeroSlotsUI(rec, ctx)
        local grid = mw.html.create("div")
        grid:addClass("sv-slot-grid")


    for _, eff in ipairs(list) do
        local slots = {}
        if type(eff) == "table" then
        for slot = 1, 4 do
            local typeName = eff.Type and eff.Type.Name or eff.ID or "Unknown"
                slots[slot] = renderHeroSlot(slot, rec, ctx)
            local value    = eff.Value or {}
        end


            local expr = value.Expression
        local hasSlots = false
            local base = value.Base
        for _, pair in ipairs({ { 1, 2 }, { 3, 4 } }) do
            local per  = value["Per Level"]
                local left  = slots[pair[1]]
                local right = slots[pair[2]]


            local seg = typeName
                if left or right then
                        hasSlots = true


            if expr and expr ~= "" then
                        if left and right then
                seg = seg .. string.format(" (%s)", expr)
                                grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isEmpty = false }))
            end
                                grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isEmpty = false }))
                        elseif left then
                                grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isFull = true }))
                        elseif right then
                                grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isFull = true }))
                        end
                end
        end


            local detail = {}
        if not hasSlots then
            if base then
                 return ""
                table.insert(detail, string.format("Base %.2f", base))
        end
            end
            if per then
                 table.insert(detail, string.format("%.2f / Lv", per))
            end


            if #detail > 0 then
        return tostring(grid)
                seg = seg .. " – " .. table.concat(detail, ", ")
end
            end


            table.insert(parts, seg)
local function addHeroSlotsRow(tbl, slotsUI)
        if not slotsUI or slotsUI == "" then
                return
         end
         end
    end


    if #parts == 0 then
        local row = tbl:tag("tr")
         return nil
         row:addClass("sv-slot-row")
    end


    -- One per line
        local cell = row:tag("td")
    return table.concat(parts, "<br />")
        cell:attr("colspan", 2)
        cell:addClass("sv-slot-cell")
        cell:wikitext(slotsUI)
end
end


local function formatStatusApplications(list)
----------------------------------------------------------------------
    if type(list) ~= "table" or #list == 0 then
-- Plug-ins
         return nil
----------------------------------------------------------------------
    end
 
function PLUGINS.IconName(rec)
        local icon  = rec.Icon
        local title = resolveDisplayName(rec)
 
        local notesList = {}
        if type(rec.Notes) == "table" then
                for _, note in ipairs(rec.Notes) do
                        local n = trim(note)
                        if n then
                                table.insert(notesList, mw.text.nowiki(n))
                        end
                end
        elseif type(rec.Notes) == "string" then
                local n = trim(rec.Notes)
                if n then
                        notesList = { mw.text.nowiki(n) }
                end
        end
 
        local req = rec.Requirements or {}
        local reqSkillsRaw = (type(req["Required Skills"]) == "table") and req["Required Skills"] or {}
        local reqWeaponsRaw = (type(req["Required Weapons"]) == "table") and req["Required Weapons"] or {}
        local reqStancesRaw = (type(req["Required Stances"]) == "table") and req["Required Stances"] or {}
 
        local reqSkills = {}
        for _, rs in ipairs(reqSkillsRaw) do
                if type(rs) == "table" then
                        local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or rs["Skill Name"] or rs["Skill ID"] or "Unknown"
                        local lvlReq  = rs["Required Level"]
                        if lvlReq then
                                table.insert(reqSkills, string.format("%s (Lv.%s)", mw.text.nowiki(nameReq), mw.text.nowiki(tostring(lvlReq))))
                        else
                                table.insert(reqSkills, mw.text.nowiki(nameReq))
                        end
                end
        end
 
        local reqWeapons = {}
        for _, w in ipairs(reqWeaponsRaw) do
                local wn = trim(w)
                if wn then table.insert(reqWeapons, mw.text.nowiki(wn)) end
        end
 
        local reqStances = {}
        for _, s in ipairs(reqStancesRaw) do
                local sn = trim(s)
                if sn then table.insert(reqStances, mw.text.nowiki(sn)) end
        end
 
        local hasNotes = (#notesList > 0)
        local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0)
 
        local wrap = mw.html.create("div")
        wrap:addClass("sv-herobar-1-wrap")
        wrap:addClass("sv-tip-scope")
 
        local iconBox = wrap:tag("div")
        iconBox:addClass("sv-herobar-icon")
 
        if icon and icon ~= "" then
                iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon))
        end
 
        local textBox = wrap:tag("div")
        textBox:addClass("sv-herobar-text")
 
        local titleRow = textBox:tag("div")
        titleRow:addClass("sv-herobar-title-row")
 
        local titleBox = titleRow:tag("div")
        titleBox:addClass("spiritvale-infobox-title")
        titleBox:wikitext(title)
 
        if hasNotes then
                local notesBtn = mw.html.create("span")
                notesBtn:addClass("sv-tip-btn sv-tip-btn--notes")
                notesBtn:attr("role", "button")
                notesBtn:attr("tabindex", "0")
                notesBtn:attr("data-sv-tip", "notes")
                notesBtn:attr("aria-label", "Notes")
                notesBtn:attr("aria-expanded", "false")
                notesBtn:tag("span"):addClass("sv-ico sv-ico--info"):attr("aria-hidden", "true"):wikitext("i")
                titleRow:node(notesBtn)
        end
 
        if hasReq then
                local pillRow = wrap:tag("div")
                pillRow:addClass("sv-pill-row")
                pillRow:addClass("sv-pill-row--req")
                local pill = pillRow:tag("span")
                pill:addClass("sv-pill sv-pill--req sv-tip-btn")
                pill:attr("role", "button")
                pill:attr("tabindex", "0")
                pill:attr("data-sv-tip", "req")
                pill:attr("aria-label", "Requirements")
                pill:attr("aria-expanded", "false")
                pill:wikitext("Requirements")
        end
 
         if hasNotes then
                local notesContent = wrap:tag("div")
                notesContent:addClass("sv-tip-content")
                notesContent:attr("data-sv-tip-content", "notes")
                notesContent:tag("div"):addClass("sv-tip-title"):wikitext("Notes")
                notesContent:tag("div"):wikitext(table.concat(notesList, "<br />"))
        end
 
        if hasReq then
                local reqContent = wrap:tag("div")
                reqContent:addClass("sv-tip-content")
                reqContent:attr("data-sv-tip-content", "req")


    local parts = {}
                if #reqSkills > 0 then
    for _, s in ipairs(list) do
                        local section = reqContent:tag("div")
        if type(s) == "table" then
                        section:addClass("sv-tip-section")
            local scope  = s.Scope or "Target"
                        section:tag("span"):addClass("sv-tip-label"):wikitext("Required Skills")
            local name  = s["Status Name"] or s["Status ID"] or "Unknown status"
                        section:tag("div"):wikitext(table.concat(reqSkills, "<br />"))
            local dur    = s.Duration and s.Duration.Base
                end
            local chance = s.Chance and s.Chance.Base


            local seg = name
                if #reqWeapons > 0 then
            if scope and scope ~= "" then
                        local section = reqContent:tag("div")
                seg = scope .. ": " .. seg
                        section:addClass("sv-tip-section")
            end
                        section:tag("span"):addClass("sv-tip-label"):wikitext("Required Weapons")
                        section:tag("div"):wikitext(table.concat(reqWeapons, ", "))
                end


            local detailParts = {}
                if #reqStances > 0 then
            if dur then
                        local section = reqContent:tag("div")
                table.insert(detailParts, string.format("Dur %.2f", dur))
                        section:addClass("sv-tip-section")
            end
                        section:tag("span"):addClass("sv-tip-label"):wikitext("Required Stances")
            if chance then
                        section:tag("div"):wikitext(table.concat(reqStances, ", "))
                table.insert(detailParts, string.format("Chance %.2f", chance))
                end
            end
        end


            if #detailParts > 0 then
        return {
                 seg = seg .. " (" .. table.concat(detailParts, ", ") .. ")"
                 inner = tostring(wrap),
            end
                classes = "module-icon-name",
        }
end


            table.insert(parts, seg)
function PLUGINS.Description(rec)
        local desc = trim(rec.Description)
        if not desc then
                return nil
         end
         end
    end


    if #parts == 0 then
        local body = mw.html.create("div")
         return nil
        body:addClass("sv-description")
    end
        body:wikitext(string.format("''%s''", desc))
 
        return {
                inner = tostring(body),
                classes = "module-description",
        }
end
 
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 table.concat(parts, "<br />")
        return {
                inner = tostring(inner),
                classes = "module-level-selector",
        }
end
end


local function formatNotes(notes)
function PLUGINS.PassiveEffects(rec, ctx)
    if type(notes) ~= "table" or #notes == 0 then
        local effects = rec["Passive Effects"]
        return nil
        if type(effects) ~= "table" or #effects == 0 then
    end
                return nil
    return table.concat(notes, "<br />")
        end
 
        local root = mw.html.create("div")
        root:addClass("sv-passive-effects")
        root:addClass("sv-compact-root")
 
        local tbl = root:tag("div")
        tbl:addClass("sv-pe-table")
 
        local added = false
        local maxLevel = ctx.maxLevel or 1
        local level = ctx.level or 1
 
        for _, eff in ipairs(effects) do
                if type(eff) == "table" then
                        local t = eff.Type or {}
                        local label = t.Name or t.ID or eff.ID or "Unknown"
 
                        local valueBlock = eff.Value
                        local display
                        if type(valueBlock) == "table" and (valueBlock.Base ~= nil or valueBlock["Per Level"] ~= nil or type(valueBlock["Per Level"]) == "table") then
                                display = displayFromSeries(seriesFromValuePair(valueBlock, maxLevel), level)
                        elseif valueBlock ~= nil then
                                display = mw.text.nowiki(tostring(valueBlock))
                        end
 
                        if not display and type(valueBlock) == "table" then
                                local expr = valueBlock.Expression or valueBlock.expression
                                if expr and trim(tostring(expr)) then
                                        display = mw.text.nowiki(tostring(expr))
                                end
                        end
 
                        if not display then
                                local expr = eff.Expression or eff.expression
                                if expr and trim(tostring(expr)) then
                                        display = mw.text.nowiki(tostring(expr))
                                end
                        end
 
                        local qual = eff.Weapon or eff["Weapon"] or eff["Weapon Type"] or eff.Stance or eff["Stance"] or eff["Stance Type"]
                        if display and qual and trim(tostring(qual)) then
                                display = tostring(display) .. " (" .. mw.text.nowiki(tostring(qual)) .. ")"
                        end
 
                        if display then
                                added = true
                                local row = tbl:tag("div")
                                row:addClass("sv-pe-row")
                                row:tag("div"):addClass("sv-pe-label"):wikitext(mw.text.nowiki(label))
                                row:tag("div"):addClass("sv-pe-value"):wikitext(display)
                        end
                end
        end
 
        if not added then
                return nil
        end
 
        return {
                inner = tostring(root),
                classes = "module-passive-effects",
        }
end
end


Line 176: Line 729:
----------------------------------------------------------------------
----------------------------------------------------------------------


local function buildInfobox(rec)
local function buildInfobox(rec, opts)
    local root = mw.html.create("table")
        opts = opts or {}
    root:addClass("wikitable spiritvale-passive-infobox")


    -- Header: icon + name
        local maxLevel = tonumber(rec["Max Level"]) or 1
    local icon  = rec.Icon
        if maxLevel < 1 then maxLevel = 1 end
    local title = rec.Name or rec["Internal Name"] or "Unknown Passive"
        local level = clamp(maxLevel, 1, maxLevel)


    local header = root:tag("tr")
        local ctx = {
    local headerCell = header:tag("th")
                maxLevel = maxLevel,
    headerCell:attr("colspan", 2)
                level = level,
        }


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


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


    ------------------------------------------------------------------
        local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
    -- Basic info
        if internalId then
    ------------------------------------------------------------------
                root:attr("data-passive-id", internalId)
    addRow(root, "Description", rec.Description)
        end
    addRow(root, "Max level", rec["Max Level"] and tostring(rec["Max Level"]))
 
        addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx))
 
        return tostring(root)
end


    ------------------------------------------------------------------
----------------------------------------------------------------------
    -- Users
-- Public: list all passives for a given user/class
    ------------------------------------------------------------------
----------------------------------------------------------------------
    local users = rec.Users or {}
    addRow(root, "Classes",  listToText(users.Classes))
    addRow(root, "Summons",  listToText(users.Summons))
    addRow(root, "Monsters", listToText(users.Monsters))
    addRow(root, "Events",  listToText(users.Events))


    ------------------------------------------------------------------
function p.listForUser(frame)
    -- Requirements
        local args = getArgs(frame)
    ------------------------------------------------------------------
    local req = rec.Requirements or {}


    if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
         local userName = args.user or args[1]
         local skillParts = {}
        if not userName or userName == "" then
        for _, rs in ipairs(req["Required Skills"]) do
                 userName = mw.title.getCurrentTitle().text
            local name  = rs["Skill Name"] or rs["Skill ID"] or "Unknown"
            local level = rs["Required Level"]
            if level then
                 table.insert(skillParts, string.format("%s (Lv.%s)", name, level))
            else
                table.insert(skillParts, name)
            end
         end
         end
        addRow(root, "Required skills", table.concat(skillParts, ", "))
    end


    addRow(root, "Required weapons", listToText(req["Required Weapons"]))
        if not userName or userName == "" then
    addRow(root, "Required stances", listToText(req["Required Stances"]))
                return "<strong>No user name provided to Passive list.</strong>"
        end


    ------------------------------------------------------------------
        local dataset = getPassives()
    -- Passive effects
        local matches = {}
    ------------------------------------------------------------------
 
    local effectsText = formatPassiveEffects(rec["Passive Effects"])
        for _, rec in ipairs(dataset.records or {}) do
    addRow(root, "Passive effects", effectsText)
                if passiveMatchesUser(rec, userName) then
                        table.insert(matches, rec)
                end
        end


    ------------------------------------------------------------------
        if #matches == 0 then
    -- Status interactions
                return string.format(
    ------------------------------------------------------------------
                        "<strong>No passives found for:</strong> %s",
    local statusApps = formatStatusApplications(rec["Status Applications"])
                        mw.text.nowiki(userName)
    addRow(root, "Status applications", statusApps)
                )
        end


    ------------------------------------------------------------------
        local parts = {}
    -- Notes
        for _, rec in ipairs(matches) do
    ------------------------------------------------------------------
                local title = resolveDisplayName(rec)
    local notesText = formatNotes(rec.Notes)
                table.insert(parts, string.format("=== %s ===\n%s", title, buildInfobox(rec, { inList = true })))
    addRow(root, "Notes", notesText)
        end


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


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Public entry point
-- Public: single-passive or auto-list dispatcher
----------------------------------------------------------------------
----------------------------------------------------------------------


function p.infobox(frame)
function p.infobox(frame)
    local args = getArgs(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 = findPassiveByName(name)
        end
 
        if not rec and id and id ~= "" then
                rec = getPassiveById(id)
        end


    -- Allow:
        if not rec then
    --  {{Passive|Honed Blade}}          -> args[1] = "Honed Blade" (Name)
                local pageTitle = mw.title.getCurrentTitle()
    --  {{Passive|name=Honed Blade}}      -> args.name
                local pageName  = pageTitle and pageTitle.text or ""
    --  {{Passive|id=CritMastery}}        -> args.id (Internal Name)
    local raw1 = args[1]
    local name = args.name or raw1
    local id  = args.id


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


    -- 1) Prefer display Name (what editors actually know)
                if noExplicitArgs then
    if name and name ~= "" then
                        return p.listForUser(frame)
        rec = findPassiveByName(name)
                end
    end


    -- 2) Fallback: Internal Name if explicitly given
                if name and name ~= "" and name == pageName and (not id or id == "") then
    if not rec and id and id ~= "" then
                        return p.listForUser(frame)
        rec = getPassiveById(id)
                end
    end


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


    return buildInfobox(rec)
        return buildInfobox(rec)
end
end


return p
return p

Latest revision as of 19:18, 21 December 2025

Module:GamePassives

Module:GamePassives renders passive skill data from Data:passives.json into a reusable infobox-style table.

It is intended to be used via a template (for example Template:Passive) so that passives can be embedded on any page without creating individual pages for each passive.

This module:

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

Data source

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

{
  "version": "SpiritVale-0.9.3",
  "schema_version": 1,
  "generated_at": "2025-12-12T17:24:05.784284+00:00",
  "records": [
    {
      "Name": "Honed Blade",
      "Internal Name": "CritMastery",
      "...": "other fields specific to passives"
    }
  ]
}

Each record is a single passive. 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.

Common fields include:

  • Icon
  • Description
  • Max Level
  • Users (e.g. Classes)
  • Requirements
  • Passive Effects
  • Status Applications
  • Notes

Output

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

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

  • Header row with passive name (and icon, if present).
  • Description.
  • Max level.
  • Users:
    • Classes
    • Summons
    • Monsters
    • Events
  • Requirements:
    • Required Skills (with required level)
    • Required Weapons
    • Required Stances
  • Passive effects:
    • Each entry from "Passive Effects" (Type name, optional Expression, Base and Per Level where present)
  • Status interactions:
    • Status Applications (if any)
  • Notes:
    • Any entries from "Notes" (one per line)

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


Public interface

The module exposes a single entry point for templates:

GamePassives.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 passive "Name".
  • name – explicit display "Name" of the passive (equivalent to 1).
  • id"Internal Name" of the passive (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 passives found for: GamePassives

or:

No passives found for: GamePassives

Template:Passive

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

Template:Passive
 No passives found for: GamePassives

Typical usage on any page:

Honed Blade
A life of combat has perfected your edge, each critical strike hits with ruthless efficiency.
Level 10 / 10
<input type="range" min="1" max="10" value="10" class="sv-level-range" aria-label="Skill level select" />
Critical Damage
30%

or, explicitly:

Honed Blade
A life of combat has perfected your edge, each critical strike hits with ruthless efficiency.
Level 10 / 10
<input type="range" min="1" max="10" value="10" class="sv-level-range" aria-label="Skill level select" />
Critical Damage
30%

Internal IDs can still be used when needed:

Honed Blade
A life of combat has perfected your edge, each critical strike hits with ruthless efficiency.
Level 10 / 10
<input type="range" min="1" max="10" value="10" class="sv-level-range" aria-label="Skill level select" />
Critical Damage
30%

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


-- Module:GamePassives
--
-- Phase 6.5+ Slot/Grid architecture (aligned with Module:GameSkills).
--
-- Layout:
--   Row 1: Slot 1 + Slot 2 (IconName + Description)
--   Row 2: Slot 3 + Slot 4 (LevelSelector + PassiveEffects)
--
-- Requires Common.js:
--   - updates .sv-dyn spans via data-series
--   - updates .sv-level-num + data-level on .sv-passive-card
--   - binds to input.sv-level-range inside each card
--
-- Usage (single passive):
--   {{Passive|Honed Blade}}
--   {{Passive|name=Honed Blade}}
--   {{Passive|id=CritMastery}}
--
-- Usage (auto-list on class page, e.g. "Acolyte"):
--   {{Passive}}                 -> lists all Acolyte passives (page name)
--   {{Passive|Acolyte}}         -> same, if no passive literally called "Acolyte"

local GameData = require("Module:GameData")

local p = {}

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

local passivesCache

local function getPassives()
        if not passivesCache then
                passivesCache = GameData.loadPassives()
        end
        return passivesCache
end

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

local function getArgs(frame)
        local parent = frame:getParent()
        return (parent and parent.args) or frame.args
end

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

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

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

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

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

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

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

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

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)
                if type(v) == "table" and v.Value ~= nil then
                        if v.Unit then
                                return tostring(v.Value) .. " " .. tostring(v.Unit)
                        end
                        return tostring(v.Value)
                end
                return (v ~= nil) and tostring(v) or nil
        end

        local series = {}

        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 isNoneLike(raw) or isNoneLike(one) or one == "0" then
                                one = "—"
                        end
                        series[lv] = one
                end
                return series
        end

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

        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 then
                                one = "—"
                        end
                        series[lv] = one
                end
                return series
        end

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

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

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

local function getPassiveById(id)
        id = trim(id)
        if not id then return nil end
        local dataset = getPassives()
        local byId = dataset.byId or {}
        return byId[id]
end

local function findPassiveByName(name)
        name = trim(name)
        if not name then return nil end

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

        return nil
end

local function resolveDisplayName(rec)
        if type(rec) ~= "table" then
                return tostring(rec or "")
        end
        return rec["External Name"] or rec.Name or rec["Display Name"] or rec["Internal Name"] or rec.ID or "Unknown Passive"
end

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

local function passiveMatchesUser(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

----------------------------------------------------------------------
-- Slot config
----------------------------------------------------------------------

local HERO_SLOT_ASSIGNMENT = {
        [1] = "IconName",
        [2] = "Description",
        [3] = "LevelSelector",
        [4] = "PassiveEffects",
}

local PLUGINS = {}

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

local function slotBox(slot, extraClasses, innerHtml, opts)
        opts = opts or {}

        local box = mw.html.create("div")
        box:addClass("sv-slot")
        box:addClass("sv-slot--" .. tostring(slot))
        box:attr("data-hero-slot", tostring(slot))

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

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

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

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

        return tostring(box)
end

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

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

local function isEmptySlotContent(inner)
        if inner == nil then return true end

        local raw = tostring(inner)

        for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do
                if mw.ustring.find(raw, pat) then
                        return false
                end
        end

        local trimmed = mw.text.trim(raw)
        if trimmed == "" or trimmed == "—" then
                return true
        end

        local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", ""))
        return (withoutTags == "" or withoutTags == "—")
end

local function renderHeroSlot(slotIndex, rec, ctx)
        local pluginName = HERO_SLOT_ASSIGNMENT[slotIndex]
        if not pluginName then
                return nil
        end

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

        return {
                inner = res.inner,
                classes = res.classes,
        }
end

local function buildHeroSlotsUI(rec, ctx)
        local grid = mw.html.create("div")
        grid:addClass("sv-slot-grid")

        local slots = {}
        for slot = 1, 4 do
                slots[slot] = renderHeroSlot(slot, rec, ctx)
        end

        local hasSlots = false
        for _, pair in ipairs({ { 1, 2 }, { 3, 4 } }) do
                local left  = slots[pair[1]]
                local right = slots[pair[2]]

                if left or right then
                        hasSlots = true

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

        if not hasSlots then
                return ""
        end

        return tostring(grid)
end

local function addHeroSlotsRow(tbl, slotsUI)
        if not slotsUI or slotsUI == "" then
                return
        end

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

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

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

function PLUGINS.IconName(rec)
        local icon  = rec.Icon
        local title = resolveDisplayName(rec)

        local notesList = {}
        if type(rec.Notes) == "table" then
                for _, note in ipairs(rec.Notes) do
                        local n = trim(note)
                        if n then
                                table.insert(notesList, mw.text.nowiki(n))
                        end
                end
        elseif type(rec.Notes) == "string" then
                local n = trim(rec.Notes)
                if n then
                        notesList = { mw.text.nowiki(n) }
                end
        end

        local req = rec.Requirements or {}
        local reqSkillsRaw = (type(req["Required Skills"]) == "table") and req["Required Skills"] or {}
        local reqWeaponsRaw = (type(req["Required Weapons"]) == "table") and req["Required Weapons"] or {}
        local reqStancesRaw = (type(req["Required Stances"]) == "table") and req["Required Stances"] or {}

        local reqSkills = {}
        for _, rs in ipairs(reqSkillsRaw) do
                if type(rs) == "table" then
                        local nameReq = rs["Skill External Name"] or rs["Skill Internal Name"] or rs["Skill Name"] or rs["Skill ID"] or "Unknown"
                        local lvlReq  = rs["Required Level"]
                        if lvlReq then
                                table.insert(reqSkills, string.format("%s (Lv.%s)", mw.text.nowiki(nameReq), mw.text.nowiki(tostring(lvlReq))))
                        else
                                table.insert(reqSkills, mw.text.nowiki(nameReq))
                        end
                end
        end

        local reqWeapons = {}
        for _, w in ipairs(reqWeaponsRaw) do
                local wn = trim(w)
                if wn then table.insert(reqWeapons, mw.text.nowiki(wn)) end
        end

        local reqStances = {}
        for _, s in ipairs(reqStancesRaw) do
                local sn = trim(s)
                if sn then table.insert(reqStances, mw.text.nowiki(sn)) end
        end

        local hasNotes = (#notesList > 0)
        local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0)

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

        local iconBox = wrap:tag("div")
        iconBox:addClass("sv-herobar-icon")

        if icon and icon ~= "" then
                iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon))
        end

        local textBox = wrap:tag("div")
        textBox:addClass("sv-herobar-text")

        local titleRow = textBox:tag("div")
        titleRow:addClass("sv-herobar-title-row")

        local titleBox = titleRow:tag("div")
        titleBox:addClass("spiritvale-infobox-title")
        titleBox:wikitext(title)

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

        if hasReq then
                local pillRow = wrap:tag("div")
                pillRow:addClass("sv-pill-row")
                pillRow:addClass("sv-pill-row--req")
                local pill = pillRow:tag("span")
                pill:addClass("sv-pill sv-pill--req sv-tip-btn")
                pill:attr("role", "button")
                pill:attr("tabindex", "0")
                pill:attr("data-sv-tip", "req")
                pill:attr("aria-label", "Requirements")
                pill:attr("aria-expanded", "false")
                pill:wikitext("Requirements")
        end

        if hasNotes then
                local notesContent = wrap:tag("div")
                notesContent:addClass("sv-tip-content")
                notesContent:attr("data-sv-tip-content", "notes")
                notesContent:tag("div"):addClass("sv-tip-title"):wikitext("Notes")
                notesContent:tag("div"):wikitext(table.concat(notesList, "<br />"))
        end

        if hasReq then
                local reqContent = wrap:tag("div")
                reqContent:addClass("sv-tip-content")
                reqContent:attr("data-sv-tip-content", "req")

                if #reqSkills > 0 then
                        local section = reqContent:tag("div")
                        section:addClass("sv-tip-section")
                        section:tag("span"):addClass("sv-tip-label"):wikitext("Required Skills")
                        section:tag("div"):wikitext(table.concat(reqSkills, "<br />"))
                end

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

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

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

function PLUGINS.Description(rec)
        local desc = trim(rec.Description)
        if not desc then
                return nil
        end

        local body = mw.html.create("div")
        body:addClass("sv-description")
        body:wikitext(string.format("''%s''", desc))

        return {
                inner = tostring(body),
                classes = "module-description",
        }
end

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

function PLUGINS.PassiveEffects(rec, ctx)
        local effects = rec["Passive Effects"]
        if type(effects) ~= "table" or #effects == 0 then
                return nil
        end

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

        local tbl = root:tag("div")
        tbl:addClass("sv-pe-table")

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

        for _, eff in ipairs(effects) do
                if type(eff) == "table" then
                        local t = eff.Type or {}
                        local label = t.Name or t.ID or eff.ID or "Unknown"

                        local valueBlock = eff.Value
                        local display
                        if type(valueBlock) == "table" and (valueBlock.Base ~= nil or valueBlock["Per Level"] ~= nil or type(valueBlock["Per Level"]) == "table") then
                                display = displayFromSeries(seriesFromValuePair(valueBlock, maxLevel), level)
                        elseif valueBlock ~= nil then
                                display = mw.text.nowiki(tostring(valueBlock))
                        end

                        if not display and type(valueBlock) == "table" then
                                local expr = valueBlock.Expression or valueBlock.expression
                                if expr and trim(tostring(expr)) then
                                        display = mw.text.nowiki(tostring(expr))
                                end
                        end

                        if not display then
                                local expr = eff.Expression or eff.expression
                                if expr and trim(tostring(expr)) then
                                        display = mw.text.nowiki(tostring(expr))
                                end
                        end

                        local qual = eff.Weapon or eff["Weapon"] or eff["Weapon Type"] or eff.Stance or eff["Stance"] or eff["Stance Type"]
                        if display and qual and trim(tostring(qual)) then
                                display = tostring(display) .. " (" .. mw.text.nowiki(tostring(qual)) .. ")"
                        end

                        if display then
                                added = true
                                local row = tbl:tag("div")
                                row:addClass("sv-pe-row")
                                row:tag("div"):addClass("sv-pe-label"):wikitext(mw.text.nowiki(label))
                                row:tag("div"):addClass("sv-pe-value"):wikitext(display)
                        end
                end
        end

        if not added then
                return nil
        end

        return {
                inner = tostring(root),
                classes = "module-passive-effects",
        }
end

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

local function buildInfobox(rec, opts)
        opts = opts or {}

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

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

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

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

        addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx))

        return tostring(root)
end

----------------------------------------------------------------------
-- Public: list all passives 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 Passive list.</strong>"
        end

        local dataset = getPassives()
        local matches = {}

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

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

        local parts = {}
        for _, rec in ipairs(matches) do
                local title = resolveDisplayName(rec)
                table.insert(parts, string.format("=== %s ===\n%s", title, buildInfobox(rec, { inList = true })))
        end

        return table.concat(parts, "\n")
end

----------------------------------------------------------------------
-- Public: single-passive 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 = findPassiveByName(name)
        end

        if not rec and id and id ~= "" then
                rec = getPassiveById(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 passive:</strong> %s[[Category:Pages with unknown passive|%s]]",
                        mw.text.nowiki(label),
                        label
                )
        end

        return buildInfobox(rec)
end

return p