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
 
(12 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
    end
 
    return frame.args
local function trim(s)
        if type(s) ~= "string" then
                return nil
        end
        s = mw.text.trim(s)
        return (s ~= "" and s) or nil
end
end


local function listToText(list)
local function toNum(v)
    if type(list) ~= "table" or #list == 0 then
        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
 
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
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 formatPassiveEffects(list)
local function passiveMatchesUser(rec, userName)
    if type(list) ~= "table" or #list == 0 then
        if type(rec) ~= "table" or not userName or userName == "" then
         return nil
                return false
    end
        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


    local parts = {}
        if opts.isEmpty then
                box:addClass("sv-slot--empty")
        end


    for _, eff in ipairs(list) do
        local body = box:tag("div"):addClass("sv-slot__body")
         if type(eff) == "table" then
         if innerHtml and innerHtml ~= "" then
            local typeName = eff.Type and eff.Type.Name or eff.ID or "Unknown"
                body:wikitext(innerHtml)
            local value    = eff.Value or {}
        end


            local expr = value.Expression
        return tostring(box)
            local base = value.Base
end
            local per  = value["Per Level"]


            local seg = typeName
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


            if expr and expr ~= "" then
local function safeCallPlugin(name, rec, ctx)
                 seg = seg .. string.format(" (%s)", expr)
        local fn = PLUGINS[name]
            end
        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 detail = {}
local function isEmptySlotContent(inner)
            if base then
        if inner == nil then return true end
                table.insert(detail, string.format("Base %.2f", base))
            end
            if per then
                table.insert(detail, string.format("%.2f / Lv", per))
            end


            if #detail > 0 then
        local raw = tostring(inner)
                seg = seg .. " – " .. table.concat(detail, ", ")
            end


            table.insert(parts, seg)
        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
         end
    end


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


    -- One per line
        local withoutTags = mw.text.trim(mw.ustring.gsub(trimmed, "<[^>]+>", ""))
    return table.concat(parts, "<br />")
        return (withoutTags == "" or withoutTags == "—")
end
end


local function formatStatusApplications(list)
local function renderHeroSlot(slotIndex, rec, ctx)
    if type(list) ~= "table" or #list == 0 then
        local pluginName = HERO_SLOT_ASSIGNMENT[slotIndex]
         return nil
        if not pluginName then
    end
                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)
    for _, s in ipairs(list) do
         local grid = mw.html.create("div")
         if type(s) == "table" then
        grid:addClass("sv-slot-grid")
            local scope  = s.Scope or "Target"
            local name  = s["Status Name"] or s["Status ID"] or "Unknown status"
            local dur    = s.Duration and s.Duration.Base
            local chance = s.Chance and s.Chance.Base


            local seg = name
        local slots = {}
            if scope and scope ~= "" then
        for slot = 1, 4 do
                 seg = scope .. ": " .. seg
                 slots[slot] = renderHeroSlot(slot, rec, ctx)
            end
        end


            local detailParts = {}
        local hasSlots = false
            if dur then
        for _, pair in ipairs({ { 1, 2 }, { 3, 4 } }) do
                table.insert(detailParts, string.format("Dur %.2f", dur))
                local left  = slots[pair[1]]
            end
                 local right = slots[pair[2]]
            if chance then
                 table.insert(detailParts, string.format("Chance %.2f", chance))
            end


            if #detailParts > 0 then
                if left or right then
                seg = seg .. " (" .. table.concat(detailParts, ", ") .. ")"
                        hasSlots = true
            end


            table.insert(parts, seg)
                        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 #parts == 0 then
        if not hasSlots then
        return nil
                return ""
    end
        end


    return table.concat(parts, "<br />")
        return tostring(grid)
end
end


local function formatNotes(notes)
local function addHeroSlotsRow(tbl, slotsUI)
    if type(notes) ~= "table" or #notes == 0 then
        if not slotsUI or slotsUI == "" then
        return nil
                return
    end
        end
    return table.concat(notes, "<br />")
 
        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
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Infobox builder
-- Plug-ins
----------------------------------------------------------------------
----------------------------------------------------------------------


local function buildInfobox(rec)
function PLUGINS.IconName(rec)
    local root = mw.html.create("table")
        local icon  = rec.Icon
    root:addClass("wikitable spiritvale-passive-infobox")
        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


    -- Header: icon + name
        local req = rec.Requirements or {}
    local icon  = rec.Icon
        local reqSkillsRaw = (type(req["Required Skills"]) == "table") and req["Required Skills"] or {}
    local title = rec.Name or rec["Internal Name"] or "Unknown Passive"
        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 header = root:tag("tr")
        local reqSkills = {}
    local headerCell = header:tag("th")
        for _, rs in ipairs(reqSkillsRaw) do
    headerCell:attr("colspan", 2)
                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 titleText = ""
        local reqWeapons = {}
    if icon and icon ~= "" then
        for _, w in ipairs(reqWeaponsRaw) do
        titleText = string.format("[[File:%s|64px|link=]] ", icon)
                local wn = trim(w)
    end
                if wn then table.insert(reqWeapons, mw.text.nowiki(wn)) end
    titleText = titleText .. title
        end
    headerCell:wikitext(titleText)


        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)
    -- Basic info
        local hasReq = (#reqSkills > 0) or (#reqWeapons > 0) or (#reqStances > 0)
    ------------------------------------------------------------------
    addRow(root, "Description", rec.Description)
    addRow(root, "Max level", rec["Max Level"] and tostring(rec["Max Level"]))


    ------------------------------------------------------------------
        local wrap = mw.html.create("div")
    -- Users
        wrap:addClass("sv-herobar-1-wrap")
    ------------------------------------------------------------------
        wrap:addClass("sv-tip-scope")
    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))


    ------------------------------------------------------------------
        local iconBox = wrap:tag("div")
    -- Requirements
        iconBox:addClass("sv-herobar-icon")
    ------------------------------------------------------------------
    local req = rec.Requirements or {}


    if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
        if icon and icon ~= "" then
        local skillParts = {}
                 iconBox:wikitext(string.format("[[File:%s|80px|link=]]", icon))
        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
         end
        addRow(root, "Required skills", table.concat(skillParts, ", "))
    end


    addRow(root, "Required weapons", listToText(req["Required Weapons"]))
        local textBox = wrap:tag("div")
    addRow(root, "Required stances", listToText(req["Required Stances"]))
        textBox:addClass("sv-herobar-text")


    ------------------------------------------------------------------
        local titleRow = textBox:tag("div")
    -- Passive effects
        titleRow:addClass("sv-herobar-title-row")
    ------------------------------------------------------------------
    local effectsText = formatPassiveEffects(rec["Passive Effects"])
    addRow(root, "Passive effects", effectsText)


    ------------------------------------------------------------------
        local titleBox = titleRow:tag("div")
    -- Status interactions
        titleBox:addClass("spiritvale-infobox-title")
    ------------------------------------------------------------------
        titleBox:wikitext(title)
    local statusApps = formatStatusApplications(rec["Status Applications"])
    addRow(root, "Status applications", statusApps)


    ------------------------------------------------------------------
        if hasNotes then
    -- Notes
                local notesBtn = mw.html.create("span")
    ------------------------------------------------------------------
                notesBtn:addClass("sv-tip-btn sv-tip-btn--notes")
    local notesText = formatNotes(rec.Notes)
                notesBtn:attr("role", "button")
    addRow(root, "Notes", notesText)
                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


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


local function passiveMatchesUser(rec, userName)
function PLUGINS.Description(rec)
    if type(rec) ~= "table" or not userName or userName == "" then
        local desc = trim(rec.Description)
         return false
        if not desc then
    end
                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 users = rec.Users
        local ctx = {
    if type(users) ~= "table" then
                maxLevel = maxLevel,
         return false
                level = level,
    end
         }


    local userLower = mw.ustring.lower(userName)
        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))


    local function listHas(list)
         if opts.inList then
         if type(list) ~= "table" then
                root:addClass("sv-passive-inlist")
            return false
         end
         end
         for _, v in ipairs(list) do
 
            if type(v) == "string" and mw.ustring.lower(v) == userLower then
         local internalId = trim(rec["Internal Name"] or rec.InternalID or rec.ID)
                 return true
        if internalId then
            end
                 root:attr("data-passive-id", internalId)
         end
         end
        return false
    end


    -- Adjust this if you *only* want Classes.
        addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx))
    if listHas(users.Classes)  then return true end
    if listHas(users.Summons)  then return true end
    if listHas(users.Monsters) then return true end
    if listHas(users.Events)   then return true end


    return false
        return tostring(root)
end
end
----------------------------------------------------------------------
-- Public: list all passives for a given user/class
----------------------------------------------------------------------


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


    -- Preferred explicit param, then unnamed, then fall back to the current page name.
        local userName = args.user or args[1]
    local userName = args.user or args[1]
        if not userName or userName == "" then
    if not userName or userName == "" then
                userName = mw.title.getCurrentTitle().text
        userName = mw.title.getCurrentTitle().text
        end
    end


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


    local dataset = getPassives()
        local dataset = getPassives()
    local matches = {}
        local matches = {}


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


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


    local root = mw.html.create("div")
        local parts = {}
    root:addClass("spiritvale-passive-list")
        for _, rec in ipairs(matches) do
                local title = resolveDisplayName(rec)
                table.insert(parts, string.format("=== %s ===\n%s", title, buildInfobox(rec, { inList = true })))
        end


    for _, rec in ipairs(matches) do
         return table.concat(parts, "\n")
         root:wikitext(buildInfobox(rec))
    end
 
    return tostring(root)
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)


    -- Allow:
        local raw1 = args[1]
    --  {{Passive|Honed Blade}}          -> args[1] = "Honed Blade" (Name)
        local name = args.name or raw1
    --  {{Passive|name=Honed Blade}}      -> args.name
        local id  = args.id
    --  {{Passive|id=CritMastery}}        -> args.id (Internal Name)
    local raw1 = args[1]
    local name = args.name or raw1
    local id  = args.id


    local rec
        local rec


    -- 1) Prefer display Name (what editors actually know)
        if name and name ~= "" then
    if name and name ~= "" then
                rec = findPassiveByName(name)
        rec = findPassiveByName(name)
        end
    end


    -- 2) Fallback: Internal Name if explicitly given
        if not rec and id and id ~= "" then
    if not rec and id and id ~= "" then
                rec = getPassiveById(id)
        rec = getPassiveById(id)
        end
    end


    -- 3) If still no record, decide: list mode or unknown passive?
        if not rec then
    if not rec then
                local pageTitle = mw.title.getCurrentTitle()
        local pageTitle = mw.title.getCurrentTitle()
                local pageName  = pageTitle and pageTitle.text or ""
        local pageName  = pageTitle and pageTitle.text or ""


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


        -- Case A: {{Passive}} with no parameters on a page → list for that page name.
                if noExplicitArgs then
        if noExplicitArgs then
                        return p.listForUser(frame)
            return p.listForUser(frame)
                end
        end
 
                if name and name ~= "" and name == pageName and (not id or id == "") then
                        return p.listForUser(frame)
                end


        -- Case B: {{Passive|Acolyte}} on the "Acolyte" page and no id → treat as list.
                local label = name or id or "?"
        if name and name ~= "" and name == pageName and (not id or id == "") then
                return string.format(
            return p.listForUser(frame)
                        "<strong>Unknown passive:</strong> %s[[Category:Pages with unknown passive|%s]]",
                        mw.text.nowiki(label),
                        label
                )
         end
         end


         -- Otherwise, genuinely unknown passive.
         return buildInfobox(rec)
        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
 
    -- Normal single-passive behavior
    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