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
 
(3 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:GamePassives
-- Module:GamePassives
--
--
-- Renders passive skill data (from Data:passives.json) into an
-- Phase 6.5+ Slot/Grid architecture (aligned with Module:GameSkills).
-- infobox-style table and can also list all passives for a given user/class.
--
-- Layout:
--  Row 1: Slot 1 + Slot 2 (IconName + Description)
--  Row 2: Slot 3 + Slot 4 (LevelSelector + PassiveEffects)
--
-- Requires Common.js:
--  - updates .sv-dyn spans via data-series
--  - updates .sv-level-num + data-level on .sv-passive-card
--  - binds to input.sv-level-range inside each card
--
--
-- Usage (single passive):
-- Usage (single passive):
Line 18: Line 26:


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


Line 24: Line 32:


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


local function getArgs(frame)
local function getArgs(frame)
    local parent = frame:getParent()
        local parent = frame:getParent()
    if parent then
         return (parent and parent.args) or frame.args
         return parent.args
end
    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, sep)
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, sep or ", ")
end
end


-- Tag body rows so we can style/center without touching the hero row
local function clamp(n, lo, hi)
local function addRow(tbl, label, value)
        if type(n) ~= "number" then
    if value == nil or value == "" then
                return lo
        return
        end
    end
        if n < lo then return lo end
    local row = tbl:tag("tr")
        if n > hi then return hi end
    row:addClass("spiritvale-passive-body-row")
        return n
    row:tag("th"):wikitext(label):done()
    row:tag("td"):wikitext(value):done()
end
end


-- Tag section header rows as body rows too (for centering)
local function fmtNum(n)
local function addSectionHeader(tbl, label)
        if type(n) ~= "number" then
    local row = tbl:tag("tr")
                return (n ~= nil) and tostring(n) or nil
    row:addClass("spiritvale-passive-body-row")
        end
 
        if math.abs(n - math.floor(n)) < 1e-9 then
                return tostring(math.floor(n))
        end


    local cell = row:tag("th")
        local s = string.format("%.4f", n)
    cell:attr("colspan", 2)
        s = mw.ustring.gsub(s, "0+$", "")
    cell:addClass("spiritvale-infobox-section-header")
        s = mw.ustring.gsub(s, "%.$", "")
    cell:wikitext(label)
        return s
end
end


-- Lookup by Internal Name
local function isNoneLike(v)
local function getPassiveById(id)
        if v == nil then return true end
    if not id or id == "" then
        local s = mw.text.trim(tostring(v))
        return nil
        if s == "" then return true end
    end
        s = mw.ustring.lower(s)
    local dataset = getPassives()
        return (s == "none" or s == "no" or s == "n/a" or s == "na" or s == "null")
    local byId = dataset.byId or {}
    return byId[id]
end
end


-- Lookup by display Name (for editors)
local function isFlatList(list)
local function findPassiveByName(name)
        if type(list) ~= "table" or #list == 0 then
    if not name or name == "" then
                return false
        return nil
        end
    end
        local first = tostring(list[1])
    local dataset = getPassives()
        for i = 2, #list do
    for _, rec in ipairs(dataset.records or {}) do
                if tostring(list[i]) ~= first then
        if rec["Name"] == name then
                        return false
            return rec
                end
         end
         end
    end
        return true
    return nil
end
end


----------------------------------------------------------------------
----------------------------------------------------------------------
-- Formatting helpers
-- Dynamic spans (JS-driven)
----------------------------------------------------------------------
----------------------------------------------------------------------


local function asUl(items)
local function dynSpan(series, level)
    if type(items) ~= "table" or #items == 0 then
        if type(series) ~= "table" or #series == 0 then
        return nil
                return nil
    end
        end
    return '<ul class="spiritvale-infobox-list"><li>'
 
         .. table.concat(items, "</li><li>")
        level = clamp(level or #series, 1, #series)
         .. "</li></ul>"
 
        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
end


local function formatBasePer(block)
local function seriesFromValuePair(block, maxLevel)
    if type(block) ~= "table" then
        if type(block) ~= "table" then
         return nil
                return nil
    end
        end
    local parts = {}
 
    if block.Base ~= nil then
         local base = block.Base
        table.insert(parts, string.format("Base %s", tostring(block.Base)))
        local per  = block["Per Level"]
    end
 
    if block["Per Level"] ~= nil then
        local function pickUnit(v)
         table.insert(parts, string.format("%s / Lv", tostring(block["Per Level"])))
                if type(v) == "table" and v.Unit and v.Unit ~= "" then
    end
                        return v.Unit
    if #parts == 0 then
                end
         return nil
                return nil
    end
        end
    return table.concat(parts, ", ")
        local unit = pickUnit(base) or pickUnit(per)
 
        local function fmtAny(v)
                if type(v) == "table" and v.Value ~= nil then
                        if v.Unit then
                                return tostring(v.Value) .. " " .. tostring(v.Unit)
                        end
                        return tostring(v.Value)
                end
                return (v ~= nil) and tostring(v) or nil
        end
 
        local series = {}
 
        if type(per) == "table" and #per > 0 then
                for lv = 1, maxLevel do
                        local raw = per[lv] or per[#per]
                        local one = fmtAny(raw)
                        if one == nil or isNoneLike(raw) or isNoneLike(one) or one == "0" then
                                one = "—"
                        end
                        series[lv] = one
                end
                return series
        end
 
         if type(per) == "table" and #per == 0 then
                local one = fmtAny(base)
                if one == nil or isNoneLike(base) or isNoneLike(one) or one == "0" then
                        one = "—"
                end
                for lv = 1, maxLevel do
                        series[lv] = one
                end
                return series
        end
 
        local baseN = toNum(base) or 0
        local perN  = toNum(per)
 
        if perN ~= nil then
                for lv = 1, maxLevel do
                        local total = baseN + (perN * lv)
                        local v = unit and { Value = total, Unit = unit } or total
                        local one = fmtAny(v)
                        if one == nil or total == 0 then
                                one = "—"
                        end
                        series[lv] = one
                end
                return series
        end
 
        local raw = (base ~= nil) and base or per
         local one = fmtAny(raw)
        if one == nil then
                return nil
        end
        if one == "0" or isNoneLike(raw) then
                one = "—"
        end
        for lv = 1, maxLevel do
                series[lv] = one
        end
        return series
end
end


-- Passive Effects: return rows (label/value), not a single text blob
local function displayFromSeries(series, level)
local function passiveEffectRows(list)
        if type(series) ~= "table" or #series == 0 then
    if type(list) ~= "table" or #list == 0 then
                return nil
        return {}
        end
    end


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


    for _, eff in ipairs(list) do
         if isFlatList(series) then
         if type(eff) == "table" then
                return mw.text.nowiki(series[1])
            local t = eff.Type or {}
        end
            local name = t.Name or eff.ID or "Unknown"
        return dynSpan(series, level)
end


            local value = eff.Value or {}
----------------------------------------------------------------------
            local detail = {}
-- Lookups
----------------------------------------------------------------------


            if value.Base ~= nil then
local function getPassiveById(id)
                table.insert(detail, string.format("Base %s", tostring(value.Base)))
        id = trim(id)
            end
        if not id then return nil end
            if value["Per Level"] ~= nil then
        local dataset = getPassives()
                table.insert(detail, string.format("%s / Lv", tostring(value["Per Level"])))
        local byId = dataset.byId or {}
            end
        return byId[id]
            if value.Expression ~= nil and value.Expression ~= "" then
end
                table.insert(detail, tostring(value.Expression))
            end


            -- Optional qualifiers (weapon/stance/etc.), if present in data
local function findPassiveByName(name)
            local qual = eff.Weapon or eff["Weapon"] or eff["Weapon Type"]
        name = trim(name)
                      or eff.Stance or eff["Stance"] or eff["Stance Type"]
        if not name then return nil end
            if type(qual) == "string" and qual ~= "" then
                table.insert(detail, qual)
            end


            local right = (#detail > 0) and table.concat(detail, ", ") or ""
        local dataset = getPassives()
            table.insert(rows, { label = name, value = right })
        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
         end
    end


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


local function formatStatusApplications(list)
----------------------------------------------------------------------
    if type(list) ~= "table" or #list == 0 then
-- User matching (for auto lists on class pages)
        return nil
----------------------------------------------------------------------
    end


    local parts = {}
local function passiveMatchesUser(rec, userName)
        if type(rec) ~= "table" or not userName or userName == "" then
                return false
        end


    for _, s in ipairs(list) do
        local users = rec.Users
         if type(s) == "table" then
         if type(users) ~= "table" then
            local scope = s.Scope or "Target"
                return false
            local name  = s["Status Name"] or s["Status ID"] or "Unknown status"
        end


            local seg = scope .. " – " .. name
        local userLower = mw.ustring.lower(userName)
            local detail = {}


            local dur = s.Duration
        local function listHas(list)
            if type(dur) == "table" then
                if type(list) ~= "table" then
                local t = formatBasePer(dur)
                        return false
                if t then
                    table.insert(detail, "Duration " .. t)
                 end
                 end
            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


            local ch = s.Chance
----------------------------------------------------------------------
            if type(ch) == "table" then
-- Slot config
                 local t = formatBasePer(ch)
----------------------------------------------------------------------
                 if t then
 
                    table.insert(detail, "Chance " .. t)
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
            end
        end
 
        if opts.isEmpty then
                box:addClass("sv-slot--empty")
        end


            if s["Fixed Duration"] then
        local body = box:tag("div"):addClass("sv-slot__body")
                 table.insert(detail, "Fixed duration")
        if innerHtml and innerHtml ~= "" then
            end
                 body:wikitext(innerHtml)
        end


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


            table.insert(parts, seg)
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
         end
    end
        return { inner = tostring(res), classes = nil }
end


    return asUl(parts)
local function safeCallPlugin(name, rec, ctx)
        local fn = PLUGINS[name]
        if type(fn) ~= "function" then
                return nil
        end
        local ok, out = pcall(fn, rec, ctx)
        if not ok then
                return nil
        end
        return normalizeResult(out)
end
end


local function formatStatusRemoval(list)
local function isEmptySlotContent(inner)
    if type(list) ~= "table" or #list == 0 then
        if inner == nil then return true end
        return nil
    end


    local parts = {}
         local raw = tostring(inner)
    for _, r in ipairs(list) do
         if type(r) == "table" then
            local names = r["Status Name"]
            local label
            if type(names) == "table" then
                label = table.concat(names, ", ")
            elseif type(names) == "string" then
                label = names
            else
                label = "Status"
            end


            local bp = formatBasePer(r)
        for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do
            local seg = label
                if mw.ustring.find(raw, pat) then
            if bp then
                        return false
                seg = seg .. " – " .. bp
                end
            end
            table.insert(parts, seg)
         end
         end
    end


    return asUl(parts)
        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
end


local function formatEvents(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 parts = {}
        local res = safeCallPlugin(pluginName, rec, ctx)
    for _, ev in ipairs(list) do
         if not res or isEmptySlotContent(res.inner) then
         if type(ev) == "table" then
                return nil
            local action = ev.Action or "On event"
            local name  = ev["Skill Name"] or ev["Skill ID"] or "Unknown skill"
            table.insert(parts, string.format("%s → %s", action, name))
         end
         end
    end


    return asUl(parts)
        return {
                inner = res.inner,
                classes = res.classes,
        }
end
end


local function formatModifiers(mods)
local function buildHeroSlotsUI(rec, ctx)
    if type(mods) ~= "table" then
        local grid = mw.html.create("div")
         return nil
        grid:addClass("sv-slot-grid")
    end
 
        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]]


    local parts = {}
                if left or right then
                        hasSlots = true


    local function collect(label, sub)
                        if left and right then
        if type(sub) ~= "table" then
                                grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isEmpty = false }))
            return
                                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
        local flags = {}
 
         for k, v in pairs(sub) do
         if not hasSlots then
            if v then
                 return ""
                 table.insert(flags, k)
            end
         end
         end
         table.sort(flags)
 
        if #flags > 0 then
         return tostring(grid)
            table.insert(parts, string.format("%s: %s", label, table.concat(flags, ", ")))
end
 
local function addHeroSlotsRow(tbl, slotsUI)
        if not slotsUI or slotsUI == "" then
                return
         end
         end
    end


    collect("Movement", mods["Movement Modifiers"])
        local row = tbl:tag("tr")
    collect("Combat",  mods["Combat Modifiers"])
        row:addClass("sv-slot-row")
    collect("Special",  mods["Special Modifiers"])


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


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


local function passiveMatchesUser(rec, userName)
function PLUGINS.IconName(rec)
    if type(rec) ~= "table" or not userName or userName == "" then
        local icon  = rec.Icon
         return false
        local title = resolveDisplayName(rec)
    end
 
        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 users = rec.Users
        local titleBox = titleRow:tag("div")
    if type(users) ~= "table" then
        titleBox:addClass("spiritvale-infobox-title")
         return false
         titleBox:wikitext(title)
    end


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


    local function listHas(list)
        if hasReq then
        if type(list) ~= "table" then
                local pillRow = wrap:tag("div")
            return false
                pillRow:addClass("sv-pill-row")
                pillRow:addClass("sv-pill-row--req")
                local pill = pillRow:tag("span")
                pill:addClass("sv-pill sv-pill--req sv-tip-btn")
                pill:attr("role", "button")
                pill:attr("tabindex", "0")
                pill:attr("data-sv-tip", "req")
                pill:attr("aria-label", "Requirements")
                pill:attr("aria-expanded", "false")
                pill:wikitext("Requirements")
         end
         end
         for _, v in ipairs(list) do
 
            if type(v) == "string" and mw.ustring.lower(v) == userLower then
         if hasNotes then
                 return true
                local notesContent = wrap:tag("div")
            end
                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
         end
        return false
    end


    if listHas(users.Classes) then return true end
        if hasReq then
    if listHas(users.Summons) then return true end
                local reqContent = wrap:tag("div")
    if listHas(users.Monsters) then return true end
                reqContent:addClass("sv-tip-content")
    if listHas(users.Events)   then return true end
                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 false
        return {
                inner = tostring(wrap),
                classes = "module-icon-name",
        }
end
end


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


local function buildInfobox(rec)
        local body = mw.html.create("div")
    local root = mw.html.create("table")
        body:addClass("sv-description")
    root:addClass("spiritvale-passive-infobox")
        body:wikitext(string.format("''%s''", desc))


    -- ==========================================================
        return {
    -- Top "hero" rows: title row (icon + name), then description row
                inner = tostring(body),
    -- (Matches the Skill infobox style)
                classes = "module-description",
    -- ==========================================================
        }
    local icon  = rec.Icon
end
    local title = rec.Name or rec["Internal Name"] or "Unknown Passive"
    local desc  = rec.Description or ""


    -- Row 1: centered icon + title (single cell)
function PLUGINS.LevelSelector(rec, ctx)
    local titleRow = root:tag("tr")
        local level = ctx.level or 1
    titleRow:addClass("spiritvale-infobox-main")
        local maxLevel = ctx.maxLevel or 1
    titleRow:addClass("sv-hero-title-row")


    local titleCell = titleRow:tag("th")
        local inner = mw.html.create("div")
    titleCell:attr("colspan", 2)
        inner:addClass("sv-level-ui")


    local titleInner = titleCell:tag("div")
        inner:tag("div")
    titleInner:addClass("spiritvale-infobox-main-left-inner")
                :addClass("sv-level-label")
                :wikitext("Level <span class=\"sv-level-num\">" .. tostring(level) .. "</span> / " .. tostring(maxLevel))


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


    titleInner:tag("div")
        if tonumber(maxLevel) and tonumber(maxLevel) > 1 then
         :addClass("spiritvale-infobox-title")
                slider:tag("input")
        :wikitext(title)
                        :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


    -- Row 2: description (single cell)
        return {
    if desc ~= "" then
                inner = tostring(inner),
        local descRow = root:tag("tr")
                classes = "module-level-selector",
        descRow:addClass("spiritvale-infobox-main")
         }
         descRow:addClass("sv-hero-desc-row")
end


         local descCell = descRow:tag("td")
function PLUGINS.PassiveEffects(rec, ctx)
         descCell:attr("colspan", 2)
         local effects = rec["Passive Effects"]
         if type(effects) ~= "table" or #effects == 0 then
                return nil
        end


         local descInner = descCell:tag("div")
         local root = mw.html.create("div")
         descInner:addClass("spiritvale-infobox-main-right-inner")
         root:addClass("sv-passive-effects")
        root:addClass("sv-compact-root")


         descInner:tag("div")
         local tbl = root:tag("div")
            :addClass("spiritvale-infobox-description")
        tbl:addClass("sv-pe-table")
            :wikitext(string.format("''%s''", desc))
    end


    ------------------------------------------------------------------
        local added = false
    -- General
        local maxLevel = ctx.maxLevel or 1
    ------------------------------------------------------------------
        local level = ctx.level or 1
    addSectionHeader(root, "General")


    addRow(root, "Max Level", rec["Max Level"] and tostring(rec["Max Level"]))
        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"


    -- Classes intentionally removed (template is used on class pages)
                        local valueBlock = eff.Value
    local users = rec.Users or {}
                        local display
    addRow(root, "Summons",  listToText(users.Summons))
                        if type(valueBlock) == "table" and (valueBlock.Base ~= nil or valueBlock["Per Level"] ~= nil or type(valueBlock["Per Level"]) == "table") then
    addRow(root, "Monsters", listToText(users.Monsters))
                                display = displayFromSeries(seriesFromValuePair(valueBlock, maxLevel), level)
    addRow(root, "Events",  listToText(users.Events))
                        elseif valueBlock ~= nil then
                                display = mw.text.nowiki(tostring(valueBlock))
                        end


    ------------------------------------------------------------------
                        if not display and type(valueBlock) == "table" then
    -- Requirements
                                local expr = valueBlock.Expression or valueBlock.expression
    ------------------------------------------------------------------
                                if expr and trim(tostring(expr)) then
    local req = rec.Requirements or {}
                                        display = mw.text.nowiki(tostring(expr))
    if (req["Required Skills"] and #req["Required Skills"] > 0)
                                end
        or (req["Required Weapons"] and #req["Required Weapons"] > 0)
                        end
        or (req["Required Stances"] and #req["Required Stances"] > 0) then


        addSectionHeader(root, "Requirements")
                        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


        if type(req["Required Skills"]) == "table" and #req["Required Skills"] > 0 then
                        local qual = eff.Weapon or eff["Weapon"] or eff["Weapon Type"] or eff.Stance or eff["Stance"] or eff["Stance Type"]
            local skillParts = {}
                        if display and qual and trim(tostring(qual)) then
            for _, rs in ipairs(req["Required Skills"]) do
                                display = tostring(display) .. " (" .. mw.text.nowiki(tostring(qual)) .. ")"
                local name  = rs["Skill Name"] or rs["Skill ID"] or "Unknown"
                        end
                local level = rs["Required Level"]
 
                if level then
                        if display then
                    table.insert(skillParts, string.format("%s (Lv.%s)", name, level))
                                added = true
                else
                                local row = tbl:tag("div")
                    table.insert(skillParts, name)
                                row:addClass("sv-pe-row")
                                row:tag("div"):addClass("sv-pe-label"):wikitext(mw.text.nowiki(label))
                                row:tag("div"):addClass("sv-pe-value"):wikitext(display)
                        end
                 end
                 end
            end
            addRow(root, "Required Skills", table.concat(skillParts, ", "))
         end
         end


         addRow(root, "Required Weapons", listToText(req["Required Weapons"]))
         if not added then
         addRow(root, "Required Stances", listToText(req["Required Stances"]))
                return nil
    end
         end


    ------------------------------------------------------------------
        return {
    -- Passive Effects (one row per effect)
                inner = tostring(root),
    ------------------------------------------------------------------
                classes = "module-passive-effects",
    local peRows = passiveEffectRows(rec["Passive Effects"])
        }
    if #peRows > 0 then
end
         addSectionHeader(root, "Passive Effects")
 
         for _, r in ipairs(peRows) do
----------------------------------------------------------------------
            addRow(root, r.label, r.value)
-- Infobox builder
         end
----------------------------------------------------------------------
    end
 
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")
    -- Status Effects
        root:addClass("spiritvale-passive-infobox")
    ------------------------------------------------------------------
        root:addClass("sv-passive-card")
    local statusApps = formatStatusApplications(rec["Status Applications"])
         root:addClass("sv-slot-card")
    local statusRem  = formatStatusRemoval(rec["Status Removal"])
         root:attr("data-max-level", tostring(maxLevel))
    if statusApps or statusRem then
         root:attr("data-level", tostring(level))
         addSectionHeader(root, "Status Effects")
         addRow(root, "Applies", statusApps)
         addRow(root, "Removes", statusRem)
    end


    ------------------------------------------------------------------
        if opts.inList then
    -- Modifiers
                root:addClass("sv-passive-inlist")
    ------------------------------------------------------------------
         end
    local modsText = formatModifiers(rec.Modifiers)
    if modsText then
        addSectionHeader(root, "Modifiers")
         addRow(root, "Flags", modsText)
    end


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


    ------------------------------------------------------------------
         addHeroSlotsRow(root, buildHeroSlotsUI(rec, ctx))
    -- Notes
    ------------------------------------------------------------------
    if type(rec.Notes) == "table" and #rec.Notes > 0 then
         addSectionHeader(root, "Notes")
        addRow(root, "Notes", asUl(rec.Notes) or table.concat(rec.Notes, "<br />"))
    end


    return tostring(root)
        return tostring(root)
end
end


Line 470: Line 767:


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


    -- Prefer 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)
    for _, rec in ipairs(matches) do
                table.insert(parts, string.format("=== %s ===\n%s", title, buildInfobox(rec, { inList = true })))
        root:wikitext(buildInfobox(rec))
        end
    end


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


Line 513: Line 808:


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


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


    local rec
        local rec


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


    -- 2) Fallback: internal ID
        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 nothing, decide if this is "list mode" or truly unknown.
        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 == "")


        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


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


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