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