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
 
(8 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


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


local function addSectionHeader(tbl, label)
local function fmtNum(n)
    local row = tbl:tag("tr")
        if type(n) ~= "number" then
    local cell = row:tag("th")
                return (n ~= nil) and tostring(n) or nil
    cell:attr("colspan", 2)
        end
    cell:addClass("spiritvale-infobox-section-header")
 
    cell:wikitext(label)
        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
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 formatBasePer(block)
local function dynSpan(series, level)
    if type(block) ~= "table" then
        if type(series) ~= "table" or #series == 0 then
        return nil
                return nil
    end
        end
    local parts = {}
 
    if block.Base ~= nil then
        level = clamp(level or #series, 1, #series)
         table.insert(parts, string.format("Base %s", tostring(block.Base)))
 
    end
         local span = mw.html.create("span")
    if block["Per Level"] ~= nil then
        span:addClass("sv-dyn")
         table.insert(parts, string.format("%s / Lv", tostring(block["Per Level"])))
         span:attr("data-series", mw.text.jsonEncode(series))
    end
        span:wikitext(mw.text.nowiki(series[level] or ""))
    if #parts == 0 then
 
         return nil
         return tostring(span)
    end
    return table.concat(parts, ", ")
end
end


local function formatPassiveEffects(list)
local function seriesFromValuePair(block, maxLevel)
    if type(list) ~= "table" or #list == 0 then
        if type(block) ~= "table" then
        return nil
                return nil
    end
        end


    local parts = {}
        local base = block.Base
        local per  = block["Per Level"]


    for _, eff in ipairs(list) do
        local function pickUnit(v)
        if type(eff) == "table" then
                if type(v) == "table" and v.Unit and v.Unit ~= "" then
            local t = eff.Type or {}
                        return v.Unit
            local name = t.Name or eff.ID or "Unknown"
                end
            local value = eff.Value or {}
                return nil
        end
        local unit = pickUnit(base) or pickUnit(per)


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


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


            local seg = name
        if type(per) == "table" and #per > 0 then
            if #detail > 0 then
                 for lv = 1, maxLevel do
                 seg = seg .. " " .. table.concat(detail, ", ")
                        local raw = per[lv] or per[#per]
            end
                        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


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


    if #parts == 0 then
        local baseN = toNum(base) or 0
        return nil
        local perN  = toNum(per)
    end
 
        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


    return table.concat(parts, "<br />")
        local raw = (base ~= nil) and base or per
        local one = fmtAny(raw)
        if one == nil then
                return nil
        end
        if one == "0" or isNoneLike(raw) then
                one = ""
        end
        for lv = 1, maxLevel do
                series[lv] = one
        end
        return series
end
end


local function formatStatusApplications(list)
local function displayFromSeries(series, level)
    if type(list) ~= "table" or #list == 0 then
        if type(series) ~= "table" or #series == 0 then
         return nil
                return nil
    end
        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


    local parts = {}
----------------------------------------------------------------------
-- Lookups
----------------------------------------------------------------------


    for _, s in ipairs(list) do
local function getPassiveById(id)
         if type(s) == "table" then
         id = trim(id)
            local scope = s.Scope or "Target"
        if not id then return nil end
            local name  = s["Status Name"] or s["Status ID"] or "Unknown status"
        local dataset = getPassives()
        local byId = dataset.byId or {}
        return byId[id]
end


            local seg = scope .. " – " .. name
local function findPassiveByName(name)
            local detail = {}
        name = trim(name)
        if not name then return nil end


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


            local ch = s.Chance
        return nil
            if type(ch) == "table" then
end
                local t = formatBasePer(ch)
                if t then
                    table.insert(detail, "Chance " .. t)
                end
            end


            if s["Fixed Duration"] then
local function resolveDisplayName(rec)
                 table.insert(detail, "Fixed duration")
        if type(rec) ~= "table" then
            end
                 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


            if #detail > 0 then
----------------------------------------------------------------------
                seg = seg .. " (" .. table.concat(detail, ", ") .. ")"
-- User matching (for auto lists on class pages)
            end
----------------------------------------------------------------------


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


    if #parts == 0 then
        local users = rec.Users
        return nil
        if type(users) ~= "table" then
    end
                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 table.concat(parts, "<br />")
        return listHas(users.Classes) or listHas(users.Summons) or listHas(users.Monsters) or listHas(users.Events)
end
end


local function formatStatusRemoval(list)
----------------------------------------------------------------------
    if type(list) ~= "table" or #list == 0 then
-- Slot config
         return nil
----------------------------------------------------------------------
    end
 
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
    for _, r in ipairs(list) do
                 box:addClass("sv-slot--empty")
         if type(r) == "table" then
        end
            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)
        local body = box:tag("div"):addClass("sv-slot__body")
            local seg = label
        if innerHtml and innerHtml ~= "" then
            if bp then
                body:wikitext(innerHtml)
                seg = seg .. " " .. bp
            end
            table.insert(parts, seg)
         end
         end
    end


    if #parts == 0 then
        return tostring(box)
         return nil
end
    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


    return table.concat(parts, "<br />")
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 formatEvents(list)
local function isEmptySlotContent(inner)
    if type(list) ~= "table" or #list == 0 then
        if inner == nil then return true end
         return nil
 
    end
         local raw = tostring(inner)


    local parts = {}
        for _, pat in ipairs({ "sv%-dyn", "data%-series", "sv%-level%-range", "sv%-level%-slider", "sv%-level%-ui" }) do
    for _, ev in ipairs(list) do
                if mw.ustring.find(raw, pat) then
        if type(ev) == "table" then
                        return false
            local action = ev.Action or "On event"
                end
            local name  = ev["Skill Name"] or ev["Skill ID"] or "Unknown skill"
            local seg    = string.format("%s → %s", action, name)
            table.insert(parts, seg)
         end
         end
    end


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


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


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


    local function collect(label, sub)
        local slots = {}
        if type(sub) ~= "table" then
        for slot = 1, 4 do
            return
                slots[slot] = renderHeroSlot(slot, rec, ctx)
         end
         end
         local flags = {}
 
         for k, v in pairs(sub) do
         local hasSlots = false
            if v then
         for _, pair in ipairs({ { 1, 2 }, { 3, 4 } }) do
                table.insert(flags, k)
                local left  = slots[pair[1]]
            end
                local right = slots[pair[2]]
 
                if left or right then
                        hasSlots = true
 
                        if left and right then
                                grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isEmpty = false }))
                                grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isEmpty = false }))
                        elseif left then
                                grid:wikitext(slotBox(pair[1], left.classes, left.inner, { isFull = true }))
                        elseif right then
                                grid:wikitext(slotBox(pair[2], right.classes, right.inner, { isFull = true }))
                        end
                end
         end
         end
        table.sort(flags)
 
         if #flags > 0 then
         if not hasSlots then
            table.insert(parts, string.format("%s: %s", label, table.concat(flags, ", ")))
                return ""
         end
         end
    end


    collect("Movement", mods["Movement Modifiers"])
        return tostring(grid)
    collect("Combat",   mods["Combat Modifiers"])
end
    collect("Special",  mods["Special Modifiers"])
 
local function addHeroSlotsRow(tbl, slotsUI)
        if not slotsUI or slotsUI == "" then
                return
        end


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


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


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


    return false
                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


----------------------------------------------------------------------
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("wikitable spiritvale-passive-infobox")
        body:wikitext(string.format("''%s''", desc))


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


    local headerRow = root:tag("tr")
function PLUGINS.LevelSelector(rec, ctx)
    headerRow:addClass("spiritvale-infobox-main")
        local level = ctx.level or 1
        local maxLevel = ctx.maxLevel or 1


    -- Left cell: icon + name
        local inner = mw.html.create("div")
    local leftCell = headerRow:tag("th")
        inner:addClass("sv-level-ui")
    leftCell:addClass("spiritvale-infobox-main-left")


    local leftInner = leftCell:tag("div")
        inner:tag("div")
    leftInner: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")
        leftInner:wikitext(string.format("[[File:%s|80px|link=]]", icon))
    end


    leftInner: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


    -- Right cell: italic description
        return {
    local rightCell = headerRow:tag("td")
                inner = tostring(inner),
    rightCell:addClass("spiritvale-infobox-main-right")
                classes = "module-level-selector",
        }
end


    local rightInner = rightCell:tag("div")
function PLUGINS.PassiveEffects(rec, ctx)
    rightInner:addClass("spiritvale-infobox-main-right-inner")
        local effects = rec["Passive Effects"]
        if type(effects) ~= "table" or #effects == 0 then
                return nil
        end


    if desc ~= "" then
        local root = mw.html.create("div")
        rightInner:tag("div")
        root:addClass("sv-passive-effects")
            :addClass("spiritvale-infobox-description")
        root:addClass("sv-compact-root")
            :wikitext(string.format("''%s''", desc))
    end


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


    -- Description now lives in the hero row.
        local added = false
    -- addRow(root, "Description", rec.Description)
        local maxLevel = ctx.maxLevel or 1
        local level = ctx.level or 1


    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"


    local users = rec.Users or {}
                        local valueBlock = eff.Value
    addRow(root, "Classes",  listToText(users.Classes))
                        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 {
                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
    -- Passive effects
        if maxLevel < 1 then maxLevel = 1 end
    ------------------------------------------------------------------
         local level = clamp(maxLevel, 1, maxLevel)
    local peText = formatPassiveEffects(rec["Passive Effects"])
    if peText then
         addSectionHeader(root, "Passive effects")
        addRow(root, "Effects", peText)
    end


    ------------------------------------------------------------------
        local ctx = {
    -- Modifiers
                maxLevel = maxLevel,
    ------------------------------------------------------------------
                level = level,
    local modsText = formatModifiers(rec.Modifiers)
         }
    if modsText then
        addSectionHeader(root, "Modifiers")
         addRow(root, "Flags", modsText)
    end


    ------------------------------------------------------------------
        local root = mw.html.create("table")
    -- Status
        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)
         if opts.inList then
         addRow(root, "Removes", statusRem)
                root:addClass("sv-passive-inlist")
    end
        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", table.concat(rec.Notes, "<br />"))
    end


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


Line 468: 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 511: Line 808:


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
        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 == "")


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