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
No edit summary
No edit summary
 
(9 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
-- Phase 6.5+ Slot/Grid architecture (aligned with Module:GameSkills).
-- infobox-style table and can also list all passives for a given user/class.
--
-- 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):
-- Usage (single passive):
Line 18: Line 26:


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


Line 24: 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, sep)
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, sep or ", ")
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
 
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
end


local function addSectionHeader(tbl, label)
local function displayFromSeries(series, level)
    local row = tbl:tag("tr")
        if type(series) ~= "table" or #series == 0 then
    local cell = row:tag("th")
                return nil
    cell:attr("colspan", 2)
        end
    cell:addClass("spiritvale-infobox-section-header")
 
    cell:wikitext(label)
        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
end


-- Lookup by Internal Name
----------------------------------------------------------------------
-- 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
         end
    end
        return rec["External Name"] or rec.Name or rec["Display Name"] or rec["Internal Name"] or rec.ID or "Unknown Passive"
    return nil
end
end


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


local function formatBasePer(block)
local function passiveMatchesUser(rec, userName)
    if type(block) ~= "table" then
        if type(rec) ~= "table" or not userName or userName == "" then
        return nil
                return false
    end
        end
    local parts = {}
 
    if block.Base ~= nil then
        local users = rec.Users
         table.insert(parts, string.format("Base %s", tostring(block.Base)))
        if type(users) ~= "table" then
    end
                return false
    if block["Per Level"] ~= nil then
         end
        table.insert(parts, string.format("%s / Lv", tostring(block["Per Level"])))
 
    end
        local userLower = mw.ustring.lower(userName)
    if #parts == 0 then
 
        return nil
        local function listHas(list)
    end
                if type(list) ~= "table" then
    return table.concat(parts, ", ")
                        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


local function formatPassiveEffects(list)
----------------------------------------------------------------------
    if type(list) ~= "table" or #list == 0 then
-- Slot config
        return nil
----------------------------------------------------------------------
    end


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


    for _, eff in ipairs(list) do
local PLUGINS = {}
        if type(eff) == "table" then
            local t = eff.Type or {}
            local name = t.Name or eff.ID or "Unknown"
            local value = eff.Value or {}


            local detail = {}
----------------------------------------------------------------------
-- Slot scaffolds
----------------------------------------------------------------------


            if value.Base ~= nil then
local function slotBox(slot, extraClasses, innerHtml, opts)
                table.insert(detail, string.format("Base %s", tostring(value.Base)))
        opts = opts or {}
            end
            if value["Per Level"] ~= nil then
                table.insert(detail, string.format("%s / Lv", tostring(value["Per Level"])))
            end
            if value.Expression ~= nil and value.Expression ~= "" then
                table.insert(detail, tostring(value.Expression))
            end


            local seg = name
        local box = mw.html.create("div")
            if #detail > 0 then
        box:addClass("sv-slot")
                seg = seg -- name
        box:addClass("sv-slot--" .. tostring(slot))
                    .. " – " .. table.concat(detail, ", ")
        box:attr("data-hero-slot", tostring(slot))
            end


            table.insert(parts, seg)
        if opts.isFull then
                box:addClass("sv-slot--full")
         end
         end
    end


    if #parts == 0 then
        if extraClasses then
         return nil
                if type(extraClasses) == "string" then
    end
                        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 table.concat(parts, "<br />")
        return tostring(box)
end
end


local function formatStatusApplications(list)
local function normalizeResult(res)
    if type(list) ~= "table" or #list == 0 then
        if res == nil then return nil end
         return nil
        if type(res) == "string" then
    end
                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 parts = {}
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


    for _, s in ipairs(list) do
local function isEmptySlotContent(inner)
         if type(s) == "table" then
         if inner == nil then return true end
            local scope = s.Scope or "Target"
            local name  = s["Status Name"] or s["Status ID"] or "Unknown status"


            local seg = scope .. " – " .. name
        local raw = tostring(inner)
            local detail = {}


            local dur = s.Duration
        for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do
            if type(dur) == "table" then
                 if mw.ustring.find(raw, pat) then
                local t = formatBasePer(dur)
                        return false
                 if t then
                    table.insert(detail, "Duration " .. t)
                 end
                 end
            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


            local ch = s.Chance
        return {
            if type(ch) == "table" then
                inner = res.inner,
                 local t = formatBasePer(ch)
                classes = res.classes,
                 if t then
        }
                    table.insert(detail, "Chance " .. t)
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
            end
        end


            if s["Fixed Duration"] then
        if not hasSlots then
                 table.insert(detail, "Fixed duration")
                 return ""
            end
        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


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


local function formatStatusRemoval(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 parts = {}
        local reqWeapons = {}
    for _, r in ipairs(list) do
        for _, w in ipairs(reqWeaponsRaw) do
        if type(r) == "table" then
                local wn = trim(w)
            local names = r["Status Name"]
                 if wn then table.insert(reqWeapons, mw.text.nowiki(wn)) end
            local label
        end
            if type(names) == "table" then
                 label = table.concat(names, ", ")
            elseif type(names) == "string" then
                label = names
            else
                label = "Status"
            end


            local bp = formatBasePer(r)
        local reqStances = {}
            local seg = label
        for _, s in ipairs(reqStancesRaw) do
            if bp then
                local sn = trim(s)
                seg = seg .. " – " .. bp
                if sn then table.insert(reqStances, mw.text.nowiki(sn)) end
            end
            table.insert(parts, seg)
         end
         end
    end


    if #parts == 0 then
        local hasNotes = (#notesList > 0)
         return nil
         local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0)
    end


    return table.concat(parts, "<br />")
        local wrap = mw.html.create("div")
end
        wrap:addClass("sv-herobar-1-wrap")
        wrap:addClass("sv-tip-scope")


local function formatEvents(list)
        local iconBox = wrap:tag("div")
    if type(list) ~= "table" or #list == 0 then
        iconBox:addClass("sv-herobar-icon")
        return nil
    end


    local parts = {}
         if icon and icon ~= "" then
    for _, ev in ipairs(list) do
                iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon))
         if type(ev) == "table" then
            local action = ev.Action or "On event"
            local name  = ev["Skill Name"] or ev["Skill ID"] or "Unknown skill"
            local seg    = string.format("%s → %s", action, name)
            table.insert(parts, seg)
         end
         end
    end


    if #parts == 0 then
        local textBox = wrap:tag("div")
         return nil
         textBox:addClass("sv-herobar-text")
    end


    return table.concat(parts, "<br />")
        local titleRow = textBox:tag("div")
end
        titleRow:addClass("sv-herobar-title-row")


local function formatModifiers(mods)
        local titleBox = titleRow:tag("div")
    if type(mods) ~= "table" then
        titleBox:addClass("spiritvale-infobox-title")
         return nil
         titleBox:wikitext(title)
    end


    local parts = {}
        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


    local function collect(label, sub)
        if hasReq then
        if type(sub) ~= "table" then
                local pillRow = wrap:tag("div")
            return
                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
         end
         local flags = {}
 
        for k, v in pairs(sub) do
         if hasNotes then
            if v then
                local notesContent = wrap:tag("div")
                 table.insert(flags, k)
                notesContent:addClass("sv-tip-content")
            end
                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
         end
         table.sort(flags)
 
        if #flags > 0 then
         if hasReq then
            table.insert(parts, string.format("%s: %s", label, table.concat(flags, ", ")))
                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
         end
    end


    collect("Movement", mods["Movement Modifiers"])
        return {
    collect("Combat",   mods["Combat Modifiers"])
                inner = tostring(wrap),
    collect("Special",  mods["Special Modifiers"])
                classes = "module-icon-name",
        }
end


    if #parts == 0 then
function PLUGINS.Description(rec)
        return nil
        local desc = trim(rec.Description)
    end
        if not desc then
                return nil
        end
 
        local body = mw.html.create("div")
        body:addClass("sv-description")
        body:wikitext(string.format("''%s''", desc))


    return table.concat(parts, "<br />")
        return {
                inner = tostring(body),
                classes = "module-description",
        }
end
end


----------------------------------------------------------------------
function PLUGINS.LevelSelector(rec, ctx)
-- User matching (for auto lists on class pages)
        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 function passiveMatchesUser(rec, userName)
        local slider = inner:tag("div"):addClass("sv-level-slider")
    if type(rec) ~= "table" or not userName or userName == "" then
        return false
    end


    local users = rec.Users
        if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
    if type(users) ~= "table" then
                slider:tag("input")
         return false
                        :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


    local userLower = mw.ustring.lower(userName)
        return {
                inner = tostring(inner),
                classes = "module-level-selector",
        }
end


    local function listHas(list)
function PLUGINS.PassiveEffects(rec, ctx)
         if type(list) ~= "table" then
        local effects = rec["Passive Effects"]
            return false
         if type(effects) ~= "table" or #effects == 0 then
                return nil
         end
         end
         for _, v in ipairs(list) do
 
            if type(v) == "string" and mw.ustring.lower(v) == userLower then
        local root = mw.html.create("div")
                return true
        root:addClass("sv-passive-effects")
            end
        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
         end
        return false
    end


    if listHas(users.Classes)  then return true end
        if not added then
    if listHas(users.Summons)  then return true end
                return nil
    if listHas(users.Monsters) then return true end
        end
    if listHas(users.Events)  then return true end


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


Line 329: 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")


    -- ==========================================================
        local maxLevel = tonumber(rec["Max Level"]) or 1
    -- Top "hero" row: icon + name (left), description (right)
        if maxLevel < 1 then maxLevel = 1 end
    -- ==========================================================
        local level = clamp(maxLevel, 1, maxLevel)
    local icon  = rec.Icon
    local title = rec.Name or rec["Internal Name"] or "Unknown Passive"
    local desc  = rec.Description or ""


    local headerRow = root:tag("tr")
        local ctx = {
    headerRow:addClass("spiritvale-infobox-main")
                maxLevel = maxLevel,
                level = level,
        }


    -- Left cell: icon + name
        local root = mw.html.create("table")
    local leftCell = headerRow:tag("th")
        root:addClass("spiritvale-passive-infobox")
    leftCell:addClass("spiritvale-infobox-main-left")
        root:addClass("sv-passive-card")
        root:addClass("sv-slot-card")
        root:attr("data-max-level", tostring(maxLevel))
        root:attr("data-level", tostring(level))


    local leftInner = leftCell:tag("div")
        if opts.inList then
    leftInner:addClass("spiritvale-infobox-main-left-inner")
                root:addClass("sv-passive-inlist")
        end


    if icon and icon ~= "" then
        local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
         leftInner:wikitext(string.format("[[File:%s|80px|link=]]", icon))
        if internalId then
    end
                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


    leftInner:tag("div")
        if not userName or userName == "" then
        :addClass("spiritvale-infobox-title")
                return "<strong>No user name provided to Passive list.</strong>"
         :wikitext(title)
         end


    -- Right cell: italic description
        local dataset = getPassives()
    local rightCell = headerRow:tag("td")
        local matches = {}
    rightCell:addClass("spiritvale-infobox-main-right")


    local rightInner = rightCell:tag("div")
        for _, rec in ipairs(dataset.records or {}) do
    rightInner:addClass("spiritvale-infobox-main-right-inner")
                if passiveMatchesUser(rec, userName) then
                        table.insert(matches, rec)
                end
        end


    if desc ~= "" then
        if #matches == 0 then
        rightInner:tag("div")
                return string.format(
            :addClass("spiritvale-infobox-description")
                        "<strong>No passives found for:</strong> %s",
            :wikitext(string.format("''%s''", desc))
                        mw.text.nowiki(userName)
    end
                )
        end


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


    -- Description is now in the hero row; don't repeat it here.
        return table.concat(parts, "\n")
    -- addRow(root, "Description", rec.Description)
end


    addRow(root, "Max level", rec["Max Level"] and tostring(rec["Max Level"]))
----------------------------------------------------------------------
-- Public: single-passive or auto-list dispatcher
----------------------------------------------------------------------


    local users = rec.Users or {}
function p.infobox(frame)
    addRow(root, "Classes",  listToText(users.Classes))
        local args = getArgs(frame)
    addRow(root, "Summons",  listToText(users.Summons))
    addRow(root, "Monsters", listToText(users.Monsters))
    addRow(root, "Events",  listToText(users.Events))


    ------------------------------------------------------------------
        local raw1 = args[1]
    -- Requirements
         local name = args.name or raw1
    ------------------------------------------------------------------
         local id  = args.id
    local req = rec.Requirements or {}
    if (req["Required Skills"] and #req["Required Skills"] > 0)
         or (req["Required Weapons"] and #req["Required Weapons"] > 0)
         or (req["Required Stances"] and #req["Required Stances"] > 0) then


         addSectionHeader(root, "Requirements")
         local rec


         if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
         if name and name ~= "" then
            local skillParts = {}
                 rec = findPassiveByName(name)
            for _, rs in ipairs(req["Required Skills"]) do
                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
            addRow(root, "Required skills", table.concat(skillParts, ", "))
         end
         end


         addRow(root, "Required weapons", listToText(req["Required Weapons"]))
         if not rec and id and id ~= "" then
         addRow(root, "Required stances", listToText(req["Required Stances"]))
                rec = getPassiveById(id)
    end
         end


    ------------------------------------------------------------------
        if not rec then
    -- Passive effects
                local pageTitle = mw.title.getCurrentTitle()
    ------------------------------------------------------------------
                local pageName  = pageTitle and pageTitle.text or ""
    local peText = formatPassiveEffects(rec["Passive Effects"])
    if peText then
        addSectionHeader(root, "Passive effects")
        addRow(root, "Effects", peText)
    end


    ------------------------------------------------------------------
                local noExplicitArgs =
    -- Modifiers
                        (not raw1 or raw1 == "") and
    ------------------------------------------------------------------
                        (not args.name or args.name == "") and
    local modsText = formatModifiers(rec.Modifiers)
                        (not id or id == "")
    if modsText then
        addSectionHeader(root, "Modifiers")
        addRow(root, "Flags", modsText)
    end


    ------------------------------------------------------------------
                if noExplicitArgs then
    -- Status
                        return p.listForUser(frame)
    ------------------------------------------------------------------
                end
    local statusApps = formatStatusApplications(rec["Status Applications"])
    local statusRem  = formatStatusRemoval(rec["Status Removal"])
    if statusApps or statusRem then
        addSectionHeader(root, "Status effects")
        addRow(root, "Applies", statusApps)
        addRow(root, "Removes", statusRem)
    end


    ------------------------------------------------------------------
                if name and name ~= "" and name == pageName and (not id or id == "") then
    -- Events
                        return p.listForUser(frame)
    ------------------------------------------------------------------
                end
    local eventsText = formatEvents(rec.Events)
    if eventsText then
        addSectionHeader(root, "Events")
        addRow(root, "Triggers", eventsText)
    end


    ------------------------------------------------------------------
                local label = name or id or "?"
    -- Notes
                return string.format(
    ------------------------------------------------------------------
                        "<strong>Unknown passive:</strong> %s[[Category:Pages with unknown passive|%s]]",
    if type(rec.Notes) == "table" and #rec.Notes > 0 then
                        mw.text.nowiki(label),
        addSectionHeader(root, "Notes")
                        label
        addRow(root, "Notes", table.concat(rec.Notes, "<br />"))
                )
    end
        end


    return tostring(root)
        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